Note
This is a daily beta build (2026-06-22). It contains the latest fixes and improvements but may have undiscovered issues.
Docker users: Update by pulling the new image:
docker pull ghcr.io/maziggy/bambuddy:daily
or
docker pull maziggy/bambuddy:daily
**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
Added
- NTP-gate state exposed on the appliance endpoint —
GET /api/v1/system/appliancegains atime_syncedfield returning"ok","warning", ornull. Source:/run/bambuddy/time-synced, written by the appliance'sntp-gate.shonce chronyd reports sync (or after a 3-minute timeout with a"warning"marker). The RPi 5 has no battery-backed RTC, so on a fresh boot the system clock is wrong until NTP catches up — JWT expiries and TLS certificate validity windows depend on this being right. Newbackend/app/core/local_config.py::read_ntp_gateis defensive on every failure mode (file absent →None, OSError →None+ warning log, empty / unknown content →None, binary garbage survives viaerrors="replace"). The endpoint stays no-auth; the SPA can use the field to render a "time not synced" badge on a fresh appliance before swapping to normal status once"ok"comes through. 8 new unit cases forread_ntp_gate(absent / ok / warning-suffixed / warning-only / empty / unknown-marker / leading-whitespace / binary-garbage) and 3 new integration cases for the endpoint field (ok / warning / absent). On Docker / manual installs the gate file doesn't exist so this is a no-op (time_syncedisnull) — the appliance is the only consumer for now. - Appliance locale defaults endpoint —
GET /api/v1/system/appliancereturns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into/etc/bambuddy/local.tomlduring firstboot. Newbackend/app/core/local_config.py::read_local_tomlparses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns{hostname, timezone, locale}withnullfor any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend,i18n/index.tsruns a one-shotapplyApplianceLocale()hook after init: gated by abambuddy_appliance_locale_consumedlocalStorage flag so it runs exactly once per appliance, fetches the endpoint, andi18n.changeLanguage(...)s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo:bambuddy-appliance); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh viahostnamectl/timedatectland don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required).
Security
- Vite 7 → 8 major bump — Bambuddy's frontend now builds with Vite 8 (
^7.3.2→^8.0.16) and the matching plugin-react release (vitejs/plugin-react^5.1.1→^5.2.0). Headline architectural change: Vite 8 swaps Rollup for Rolldown as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitiveesbuildfloor to 0.28.1, which closes the last open advisory in the audit chain. Bambuddy-side surface audited:vite.config.tsuses only stable contracts that survived the v8 cut —defineConfig, theConnecttype, the customserveGcodeViewerconfigureServermiddleware plugin (proxies/gcode-viewer/*to the repo's siblinggcode_viewer/directory in dev), theserver.proxywith WebSocket upgrade for/api/v1/ws,build.outDir/emptyOutDir/chunkSizeWarningLimit, andresolve.aliasfor ``.base: '/'regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. Node: vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. What this is NOT: plugin-react v6 — that line requires `babel-plugin-react-compiler` + `rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean. - Frontend dependency bumps — Routine version updates across the runtime, build, and test dependency surface. Runtime:
dompurify3.4.0 → 3.4.10.package.jsonfloor raised from^3.4.0to^3.4.10so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (frontend/src/pages/MakerworldPage.tsx,frontend/src/pages/ProjectDetailPage.tsx,frontend/src/components/ProjectPageModal.tsx); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list withselectedcontent+command+commandfor(all valid modern HTML, harmless for our two default-allow-list call sites), andProjectPageModalis unaffected anyway because it sets an explicitALLOWED_TAGS/ALLOWED_ATTRwhitelist. Build / lint / test tooling (transitive, dev-only):babel/core7.29.0 → 7.29.7 (pulled byvitejs/plugin-reactandeslint-plugin-react-hooks),vite7.3.2 → 7.3.5,markdown-it14.1.1 → 14.2.0 (pulled bytiptap/extension-link→tiptap/pm→prosemirror-markdown; Bambuddy never callsmarkdown-it.renderdirectly so the change is transparent),js-yaml4.1.1 → 4.2.0 (pulled byeslint),form-data4.0.5 → 4.0.6 +ws8.20.1 → 8.21.0 (both pulled byjsdomin the test runtime). All bumps inside existing semver ranges exceptdompurify. No source changes required. dompurify3.4.10 → 3.4.11 — Follow-up patch closes a moderate-severity advisory affectingsetConfig()callers: the previous hook clone-guard added in 3.4.7 could be bypassed viasetConfig(), leaving a permanentALLOWED_ATTRpollution that the nextsanitize()call inherited. Bambuddy's exposure is nil —git grep DOMPurify.setConfigreturns zero hits across the entire codebase; all three sanitisation sites (frontend/src/pages/MakerworldPage.tsx,frontend/src/pages/ProjectDetailPage.tsx,frontend/src/components/ProjectPageModal.tsx) callDOMPurify.sanitize(html)orDOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR})directly, never throughsetConfig(). The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silencenpm auditso future audit-fix runs don't auto-bundle unintended changes. Mechanical lockfile bump only: the existing^3.4.10range already permitted 3.4.11, sopackage.jsonis unchanged;package-lock.jsonupdates the resolved URL + integrity hash for the one entry. Verification:npm auditreports 0 vulnerabilities,MakerworldPage.test.tsx's 12 DOMPurify sanitisation cases pass,npm run buildclean.- Backend dependency security floor raises (cryptography / python-multipart / starlette) — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in
requirements.txtlifted to the documented fix releases, plus one transitive co-bump for resolver compatibility.cryptography46.0.7 → 48.0.1 floor (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). Release-notes audit (done before bump): v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved tocryptography_decrepit); v48.0.0 droppedPUBLIC_KEY_TYPES/PRIVATE_KEY_TYPEStype aliases. Bambuddy's grep is clean across every one of those:core/encryption.pyuses Fernet (AES-128-CBC + HMAC),services/spoolbuddy_ssh.pyuses ed25519,services/virtual_printer/certificate.pyuses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us.python-multipart0.0.27 → 0.0.31 floor (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). Behavioural changes audited: 0.0.30 stopped recognising RFC 2231/5987 extendedfilename*/name*parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (utils/http.py:17) but doesn't parse them on the request side, and clients that include bothfilename=andfilename*=keep working via the plainfilename=fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only&as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses&.starlette1.1.0 → 1.3.1 floor — clears CVE-2026-54282/54283 (FormParsermax_part_size/max_fieldslimits now actually enforced after being declared-but-ignored in earlier releases;StaticFiles.lookup_pathrejects absolute paths;FileResponseclamps oversized suffix range requests;URL.replace()IndexError fix). Critical pre-bump check: the newly-enforcedmax_part_size=1MBdefault would have broken every file upload (UploadFile = File(...)ininventory.py:1127,projects.py:886/1053/1780,library.py:1787,local_presets.py:82,external_links.py:166,local_backup.py) if it applied to file streams. Inspected theMultiPartParser.on_part_datasource: the size check atif self._current_part.file is None:only fires for text form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. Side rename:backend/app/api/routes/mfa.py:470/1364/1428replaces 3 references ofstatus.HTTP_422_UNPROCESSABLE_ENTITY(deprecated in starlette 1.3.x) withHTTP_422_UNPROCESSABLE_CONTENT. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix).pyopenssl26.0.0 → 26.3.0 floor — NOT a security fix; required because pyOpenSSL<26.3.0capscryptography<47in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no directfrom OpenSSL ...imports — pyOpenSSL is pulled transitively byasyncssh+pywebpush. Verification:pip-auditclean,pip checkclean,ruff check backend/clean, backendpytest -n 306167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change.
Added
- AMS Filament Backup is now first-class across the deficit check, the printer card, and a new BambuStudio-style backup modal (#1762, reported by jpcast2001 + Arn0uDz) — Four tightly coupled changes that close the gap reporter jpcast2001 hit on the dual-AMS X1C farm. Reporter scenario: PLA Basic in AMS-1 slot 1 with low remaining grams, the same PLA Basic in AMS-2 slot 1 with plenty — Bambuddy still blocked the print with an "insufficient filament" warning because per-slot accounting never noticed the backup peer. Reporter disabled
disable_filament_warningsas a workaround; Arn0uDz hit the same shape on a different printer and noted "Print Anyway" didn't unstick them either. The single global AMS Filament Backup toggle that shipped in 0.2.5b1 (#1766) was the prerequisite for the firmware-level switch, but every Bambuddy surface still treated each slot as isolated. What's new on each surface:
Backup-aware deficit aggregation (the load-bearing fix).backend/app/services/filament_deficit.py::compute_deficit_for_queue_itemnow readsPrinterState.ams_filament_backupviaprinter_manager.get_statusand, when backup is ON, poolsremaining_gramsacross every same-material assigned spool on the printer before deciding whether to block. Material identity uses the firmware's actual rule: same Bambu filament preset ID (Spool.slicer_filament, e.g.GFA00) AND same colour (with1A1A1AFFnormalised to match1A1A1A— alpha stripped, hex uppercased). The preset identifies the filament profile (PETG HF, PLA Basic, etc.); the colour pins the variant. Three PETG HF spools in different colours all share the same preset but absolutely don't back each other up — the firmware would correctly swap PETG HF but the print would change colour mid-run. User-tagged spools without a preset get a unique-per-spool key so they never pair with anything else, matching the firmware: Bambu's backup logic relies on the preset, and grouping on cosmetic material+colour match alone would let two visually-identical but materially-different spools be treated as backups. Same(catalog-id, colour)rule on the Spoolman side viafilament.id+color_hex; spools linked to different filament catalog entries never pool even if their material+colour strings match. Dual-extruder scoping is load-bearing here: H2D / H2C / X2D firmware cannot cross extruders even when bit 18 ofprint.cfgis set, so the pool is per-extruder-side viaPrinterStatus.ams_extruder_mapplusis_dual_nozzle_model(). Single-extruder printers collapse everything to one pool and ignore the map. The check still emits per-slotFilamentDeficitrows when the total required of a material on an extruder side exceeds the total available of that material, so the UI's "slot X is short" message still resolves to a specific slot the user can act on — it just doesn't fire spuriously when the firmware will actually save them.
BambuStudio-style backup modal opens from the badge. The existing AMS Backup badge on the Filaments section header (#1766) now opens a dedicated modal instead of toggling state on click. The modal renders one SVG ring graphic per backup pair — each ring filled with the filament colour, the material name + rotation count (N× ↻) in the centre, and member slot labels distributed around the colour band on rounded contrast-aware pills (semi-opaque black on bright fills, semi-opaque white on dark) so the labels stay legible regardless of the spool colour. Closely modelled on Bambu Studio's "Auto Refill" widget. Lone slots are intentionally suppressed — the ring graphic is the answer to "which slots will save me when this one runs out"; everything else is visual noise. On dual-extruder printers (H2D / H2C / X2D), each ring carries a compactR/Lbadge in the top-left corner instead of section headers — and the badge ONLY appears when the extruder map carries TWO distinct values across the AMS units, so single-nozzle printers misflagged as dual or printers with routing data not yet reported collapse cleanly to no-badge rendering. Modal closes on Esc keypress (window-level listener registered while open, cleaned up on close), click-outside, or the close button. Theme-aware via CSS variables (var(--bg-secondary)/var(--text-primary)/ etc.) matchingAMSHistoryModal, so the modal follows whichever background variant the user has picked (neutral / warm / cool / oled / slate / forest). The badge itself stays in the Filaments section header where #1766 put it — itsonClickwas rewired from "directly toggle the backup state" to "open the modal", with the samesetAmsFilamentBackupmutation hooked to the toggle inside the modal. The newcomputeBackupGroups(amsUnits, amsExtruderMap, isDualNozzle): BackupGroup[]helper inutils/amsHelpers.tsis the modal's data source — it returns one entry per non-empty slot, sorted with pairs first, then by material name, then by global tray id for deterministic rendering. Identity uses the same strict(preset, colour)rule as the backend. HT AMS (single-tray modules withams_id ≥ 128) participate in groupings viagetGlobalTrayId, so an HT slot can pair with a regular AMS slot when both hold the same preset and colour. Defensive dedup byams.id(first occurrence wins) hardens the helper against duplicate entries that have been observed in the wild on VP-aggregated switch printers and MQTT partial-update edge cases — without the dedup, a single physical slot could render in two different rings.
Active-print per-slot mapping pill while RUNNING / PAUSE. While the printer is mid-print, each slot tile referenced byPrinterStatus.ams_mapping(already on the wire, from the slicer's filament-map captured during dispatch) gets a small "P1 / P2 / P3 …" pill in the top-right corner — opposite the backup-group dot — naming which print-slot is mapped to this AMS slot. Catches the secondary report from jpcast2001's comment 2 verbatim: queue job set for "any X1C", scheduler bumped it to a printer with mismatched filament, no way to verify mid-print whether the right slots are loaded. With the pill the mismatch is visible the instant the print starts. The existing single-slotring-2 ring-bambu-greenhighlight foreffectiveTrayNowkeeps its meaning ("the currently-active extrusion source RIGHT NOW") — the pill is per-slot static "this slot is filament N in the active print," not per-tick dynamic.
Print Anyway diagnostic log (Arn0uDz follow-up)._block_on_filament_deficitinprint_scheduler.py:1983now logs at INFO when it honoursitem.skip_filament_check, so a future "Print Anyway didn't work" report (the third commenter on #1762 hit this shape) has an actionable line in the standard support bundle without needing debug logging enabled. The route-side log atprint_queue.py:1278is unchanged. Without logs from the original report we can't isolate the user's failure mode (the wire path on both ends still looks correct on inspection), so this is the minimal trace required to investigate the next occurrence — bundled in the same drop because Block 1 makes the original symptom disappear for users who had backup ON anyway. Tests. 8 new backend cases intest_filament_deficit.py::TestFilamentDeficitBackupAwarepin every dimension: pool covers the assigned-slot shortfall → no deficit (the reporter scenario, with matchingslicer_filamentpreset + matching colour); pool insufficient → deficit emitted with the correct slot id; peer slot holds a DIFFERENT preset → no pool, deficit fires; backup OFF → strict regression with the pre-#1762 per-slot accounting (using identical inputs to the "pool covers" case but flipping the toggle); dual-extruder printer with a peer on the OPPOSITE side → deficit fires because the firmware can't cross; STRICT-rule — two spools with material+colour match but NO preset must NEVER pair; COLOUR-strict — same preset + DIFFERENT colours must NOT pool (the reporter screenshot scenario, three PETG HF in different colours); COLOUR normalisation — 6-char and 8-char hex of the same RGB pool correctly (000000matches000000FF). 13 frontend cases inPrintersPageBackupGroups.test.tspincomputeBackupGroups: empty for missing input; ignores empty slots; pairs via preset; no-preset spools NEVER pair even on attribute-tuple match; different presets never cross; SAME preset + DIFFERENT colours don't pair; colour-hex 6-char and 8-char normalisation; lone slots returned alongside pairs in the same list; dual-extruder scopes per-side both ways; HT AMS pairs with regular AMS viagetGlobalTrayId; preserves display name + tray colour for the modal swatch; DEFENSIVE dedup of duplicateams.identries (first wins). 10 modal render cases inAmsBackupModal.test.tsx: closed → null; ring renders for pairs and OMITS lone slots; Esc keypress closes the modal; Esc is a no-op after the modal closes (listener actually unmounts); toggle reflects ON state + fires onToggle(false) on click; toggle disabled when state unknown (A1 family); toggle disabled when permission missing; no-pairs empty state when no pair can form; R/L badges render when extruder map carries two distinct values; R/L badges absent when the map collapses to one extruder. Existing 8test_filament_deficit.py+ 60PrintersPage.test.tsxcases stay green — the no-backup path is a strict no-op vs the pre-#1762 logic. i18n. 12 new keys × 11 locales for the modal + the active-print pill (printers.amsBackup.modalTitle / modalHelp / modalNoSlots / modalNoPairs / stateOn / stateOff / stateUnknown / extruderRightShort / extruderLeftShort, plusprinters.activeJobSlot.title / ariaLabel) translated in de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW. Parity check 5253 leaves per locale, no English fallback; "AMS Filament Backup" is a Bambu product/firmware name and is allowlisted as a cognate where the European locales keep it verbatim. Scope. No DB migration, no new permission. The global Filament Backup badge stays where #1766 put it (Filaments section header on the printer card) — firmware reality is one bit onprint.cfg, and moving the toggle per-AMS would misrepresent that. The badge click no longer toggles directly; it opens the modal, where the samesetAmsFilamentBackupmutation is wired to the toggle. No schema change toFilamentDeficit— same shape, same wire payload, same 409 response undercode: insufficient_filament. No change to thedisable_filament_warningssetting (#720) — when on, the deficit check is still a no-op regardless of backup state. Behaviour shift worth flagging. Pre-PR, prints could be blocked with "insufficient filament" even when the firmware would actually have switched to a same-(preset, colour)peer mid-print. Post-PR, those prints dispatch. Users with backup misconfigured at the firmware level (e.g. FTS routing wrong) may see prints dispatch that previously got blocked at the deficit check; the printer would then fail mid-run rather than at queue-start time. The trade-off is correct — the warning shouldn't fire when backup will save you — but worth surfacing for anyone debugging post-upgrade. - Inline finish-photo embed in failure-event emails +
user_print_*template disambiguation (#1792, reported by elit3ge) — Two related changes to the notification stack. (1) Template-driven inline finish-photo in email. Pushover / Telegram / Discord / ntfy users already get the finish-photo JPEG attached to terminal-print notifications (print_complete/print_failed/print_stoppedevent types), thanks to the capture path shipped in 0.2.5b1 (#1397) that extracts the last timelapse frame at print end and loads up to 2.5 MB intoarchive_data["image_data"]. Email was the one provider that dropped those bytes on the floor — text-only body, no visual context for the reporter's "Reason: unknown" failure mails.notification_service._send_email(backend/app/services/notification_service.py:413) now acceptsfinish_photo_urlalongsideimage_dataand the dispatcher (_send_to_providerat:745) threads the URL from the rendered template variables dict. Inline embed is opt-in via the existing{finish_photo_url}template variable — first draft of this fix unconditionally inlined the photo whenever bytes were present, which maziggy correctly flagged as bypassing the template system ("standard is to have variables for all available items in a template"). The contract now: if the user puts{finish_photo_url}in their email template body, the URL substring in the rendered body triggers the multipart/related shape — HTML part replaces the escaped URL in-place with<img src="cid:bambuddy-finish-photo">(so the image appears WHERE the variable was, not stapled to the bottom), plain-text part keeps the URL as a clickable link, MIMEImage attached inline withContent-ID: <bambuddy-finish-photo>per RFC 2392. If the template doesn't reference the variable, single-part text-only — no surprise image. Default templates are unchanged; reporter (and any user who wants this) edits theirprint_complete/print_failed/print_stoppedbody once to add the variable. XSS hygiene: rendered body ishtml.escaped before the URL→<img>swap, newlines become<br>. Pushover/Telegram/Discord/ntfy senders untouched — their pre-existing "auto-attach wheneverimage_datais set" behaviour stays because their bodies aren't HTML-templatable for inline images anyway. (2)user_print_*template names get an " Email" suffix. Same reporter surfaced a separate confusion: the Message Templates list showed "Print Completed" and "User Print Completed" side-by-side with no cue they're different dispatch paths — the first is a provider-level broadcast to whatever notification channels the admin configured (ntfy/pushover/telegram/discord/email/webhook/homeassistant), the second is a per-user SMTP-only email to the user who submitted the job (requires advanced auth +user_notifications_enabledtoggle + user has email + per-user pref opt-in). TheEVENT_NAMESdisplay map inbackend/app/api/routes/notification_templates.py:51already used the disambiguated "User Print Completed Email" label, but the seed wrote the short name to the DB, so the UI rendered the ambiguous one. Fresh installs now get the suffixed name straight fromDEFAULT_TEMPLATES(backend/app/models/notification_template.py:198+). Existing installs get the rename via a new_migrate_rename_user_print_template_names(backend/app/core/database.py:3081+) that runs on startup and updates rows for the fouruser_print_*event types WHERE the name still matches the old default — admin-edited names are preserved. Standard SQL UPDATE works on both SQLite and Postgres without dialect branching. Tests: 6 newTestEmailProvidercases inbackend/tests/unit/services/test_notification_service.pypinning the template-driven contract (no-image-no-URL → text-only, image-without-template-reference → STILL text-only, URL-in-body + bytes → multipart/related with cid, URL-arg-missing → text-only defence-in-depth, body-escape hygiene, URL→<img>in-place swap). 5 new migration cases inbackend/tests/unit/test_user_print_template_rename_migration.pycovering default-rename, user-edited preservation, provider-template don't-touch, second-run idempotency, empty-table fresh-install no-op. 11/11 + 140/140 adjacent notification tests green. Ruff clean. Verified end-to-end against a real SMTP provider with a real 48 KB finish-photo JPEG — Gmail rendered the inline image where the URL marker was in the body. - Dedicated "AI Failure Detection" notification event (#1794, reported by maziggy from a user report) — Obico failure detection now fires its own notification event (
on_ai_failure_detection) instead of riding the multiplexedon_printer_errortoggle. Reporter (P1S, Discord provider) had Obico enabled withobico_action=notify, detection was firing correctly per the logs, every other Discord notification was working — but spaghetti detections never reached Discord. Root cause.obico_actions._notifyatobico_actions.py:75was callingnotification_service.on_printer_error(..., error_type="ai_failure_detection"). The notification service's provider filter atnotification_service.py:722-725requires the SUBSCRIBED-event boolean column to be True; theon_printer_errorcolumn defaults to False; the reporter's Discord provider was created without explicitly enabling Printer Error. The user couldn't have found the right toggle even if they'd known to look — the UI labels it "Printer Error" with no hint that flipping it also subscribes to AI detection. The same toggle multiplexed three distinct events (HMS hardware errors atmain.py:1248+ Obico spaghetti + aerror_type="ai_failure_detection"discriminator passed in the variables payload), so a user who wanted spaghetti alerts but not chamber-fan-stalled HMS pages had no way to express that. Fix. Newon_ai_failure_detectionBoolean column onnotification_providers(defaults False — matches the conservative default of every other opt-in event); newnotification_service.on_ai_failure_detection(printer_id, printer_name, task_name, confidence, action, db, image_data)method following the exact shape ofon_printer_error(mirrors variable handling, template fan-out, provider filter, fail-open under quiet-hours / digest); newai_failure_detectiontemplate entry seeded byseed_notification_templateswith variables{printer},{task_name},{confidence},{action}. The seeder only adds templates whoseevent_typeis missing, so existing installations get the new template on next start without clobbering customised ones.obico_actions._notifyswapped to the new method. Migration. Branched SQLite (DEFAULT 0) vs Postgres (DEFAULT false) per the existing stock-alert migration shape atdatabase.py:2750— Postgres rejectsDEFAULT 0for BOOLEAN columns. Existing providers receive the column with the conservative False default; they continue NOT receiving Obico notifications UNTIL they explicitly toggle the new "AI Failure Detection" event ON. This is the intended UX: previously the toggle was onPrinter Error, which the reporter had OFF, so today they get nothing; after this change they still get nothing until they opt in via the dedicated toggle, but now they can find the toggle without trial-and-error. Frontend. New toggle row inNotificationProviderCard.tsx(between Printer Error and Low Filament) with a description line "Notify when Obico AI detects a possible print failure" so users discover the link to Obico without having to read source. New summary badge ("AI Failure Detection" in fuchsia) in the collapsed card view so admins can see at a glance which providers route AI alerts. New toggle inAddNotificationModal.tsxPrinter Status section with matching state hook (onAiFailureDetection) wired through the create + update payload. ntfy per-event priority block also picks up the new event when enabled, matching how Printer Error and the stock-alert events behave there. Schema.NotificationProvidermodel +NotificationProviderBase/NotificationProviderUpdateschemas +_provider_to_dictroute serialiser + create route + PATCH route (the latter usesmodel_dump(exclude_unset=True)so it picks up the new field automatically). FrontendNotificationProvidertype + the update-payload variant. i18n. Two new keys —notifications.aiFailureDetection(label) andnotifications.aiFailureDetectionDescription(help text) — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5242 leaves per locale, no English fallback. Tests. 4 new backend cases intest_notification_service.py::TestAIFailureDetectionNotifications(dispatch uses the new event field — NOT the legacy multiplexed one; provider with onlyon_printer_error=Trueis NOT notified — the regression guard for the reporter's symptom; variables include task_name + 2-decimal-formatted confidence + action; empty task_name falls back to "current job"). 3 new backend cases intest_obico_actions.py(execute_action(action='notify')callson_ai_failure_detectionand explicitly does NOT callon_printer_error; thepauseaction still pauses + notifies; notification-service exceptions are swallowed so a transient Discord blip can't kill the Obico detection loop). 5 new frontend cases — 4 inNotificationProviderCardAiFailureDetection.test.tsx(badge renders when ON; absent when OFF; toggle appears in expanded settings; toggling PATCHes the correct field and explicitly NOTon_printer_error) and 3 inAddNotificationModal.test.tsx(toggle renders in Printer Status section; save persists the new field without touchingon_printer_error; ntfy priority block includes the event when enabled). Existing 87test_notification_service.py+ 52 Obico tests + 65NotificationProviderCard*/AddNotificationModal*tests still green. Backendpytest -n 30clean; ruff clean;npm run buildclean; ESLint clean. Scope. No change to HMS hardware-error notifications —main.py::on_printer_errorcallers still fire theon_printer_errorevent witherror_typeshapes like"AMS Error"/"Heating Error", unchanged. Theon_printer_errorcolumn stays on the table (default False, used for HMS only). Users who had it ON for HMS errors keep getting HMS notifications; what they LOSE is silent AI-failure dispatch on the same toggle, which most users with HMS-on never received anyway becauseerror_type="ai_failure_detection"was the same valueobico_actions._notifyhardcoded. The full Obico action surface (notify/pause/pause_and_off) is unchanged on the dispatch side —execute_actionstill pauses + cuts plug power forpause_and_off; the only thing that moved is which notification-service method runs the fan-out. - Page-wide drag-and-drop upload on the File Manager (#1510, requested by maikolscripts) — File Manager gains the same drag-and-drop upload surface that the Archives page has had: drop any file anywhere on the page and the upload modal opens pre-populated with the dropped files, no need to click the Upload Files button first. The hardcoded
"Upload 3MF"flow was the only path before this change. Unlike the Archives variant — which filters dropped files to.3mfonly — the File Manager drop zone accepts whatever the upload modal itself accepts (3MF, STL, ZIP, images), so the page-wide surface is never more restrictive than the button it shortcuts. Permission-gated onlibrary:uploadso a viewer-tier user can't accidentally trigger the overlay. Shared hook.frontend/src/hooks/usePageFileDrop.tsis the new home for the drag-handler set —isDraggingOverstate,dragHandlersto spread on the wrapper, optionalextensionsfilter, optionalonRejectedcallback for "you dropped something we won't accept" toasts,disabledflag for permission gating. Archives and File Manager both consume it; future drop-zones can opt in without re-implementing the cancel-safe logic.FileUploadModal.initialFilesprop. Modal accepts aFile[]to pre-seed itself on first mount via aseededInitialRefguard so the same files don't re-add on subsequent renders. Existing manual-open paths (Upload Files button) pass nothing and behave unchanged. i18n. New keyfileManager.releaseToUploadtranslated in all 11 locales (en: Release to upload, de: Loslassen zum Hochladen, es: Suelte para subir, fr: Relâcher pour téléverser, it: Rilascia per caricare, ja: 離してアップロード, ko: 놓아서 업로드, pt-BR: Solte para enviar, tr: Yüklemek için bırakın, zh-CN: 释放以上传, zh-TW: 釋放以上傳); existingfileManager.dropFilesHerereused. Parity 5240 leaves × 11 green, no English fallback. Tests. 13 new cases insrc/__tests__/hooks/usePageFileDrop.test.tsxcovering: overlay on dragenter, non-file payload ignored, child-element dragLeave keeps overlay (relatedTarget inside wrapper), outside-element dragLeave hides it, null relatedTarget hides it (cursor left window), document drop / dragend / Escape all reset (the three cancel paths the prior inline implementation missed — see the Fixed entry), drop with mixed file types filters by extension, onRejected fires when extension filter drops everything, disabled is a no-op, overlay clears on successful drop. Existing 85 cases across ArchivesPage / FileManagerPage / FileManagerExternalFolder vitest still green. ESLint clean;npm run buildclean. - Sort Printers page by ETA (#1609, requested by forgecrafttechnologies-source) — The Printers page sort dropdown gains a fifth option, ETA, beside the existing Name / Status / Model / Location. Sorts the fleet by remaining print time so the printer that's finishing next sits at the top — the reporter's use case is staging the next job's filament ahead of time without scanning every card. Tier ordering. Tier 0 = currently printing with a known
remaining_time > 0, sorted ascending by remaining minutes (soonest first); Tier 1 = currently printing without an ETA yet (post-start_printwindow before the slicer reports total time); Tier 2 = idle / finished; Tier 3 = offline. Tiebreaker within every tier is printer name, so two printers with the same ETA — or two idle printers — stay in a stable alphabetic order. The ascending / descending direction button still applies after tiers resolve, so descending puts offline printers at the top for operators triaging the fleet for connectivity issues. Data source. The cachedremaining_time(minutes) on the per-printer status query (['printerStatus', id]) — the same field the per-card "ETA … min" label already reads from onPrintersPage.tsx:3633and the fleet-wide "next finish" badge already aggregates onPrintersPage.tsx:996. No new backend query, no new round-trip; the sort consumes data that's already in the React Query cache and updated on every WebSocket push. No grouping. Unlikestatus/model/locationsorts (which group rows under section headers), the ETA sort renders a flat list — each printer's ETA is unique so grouping would just produce a header per row. i18n. New keyprinters.sort.etatranslated in all 11 locales (en: ETA, de: Restzeit, es: Tiempo restante, fr: Temps restant, it: Tempo rimanente, ja: 残り時間, ko: 남은 시간, pt-BR: Tempo restante, tr: Kalan süre, zh-CN: 剩余时间, zh-TW: 剩餘時間), no English fallback. Parity check 5239 leaves per locale, green. ESLint clean;npm run buildclean. - Prominent sponsor banner at the top of Settings → General (the default landing tab) — Full-width gradient panel with a heart icon, a one-line independence framing, and a "View supporters" CTA linking to
bambuddy.cool/sponsors.html?from=app-settingsso Matomo can split-track this surface against the website's own positions. Motivation lives inbambuddy-install-base-2026-06-20.md: re-baselining install count via the ghcr.io pull counter (~10k pulls/day rising) puts active deployments around 8-12k, and at 8 sponsors (per [[sponsor-portal]]) that's 0.08% conversion — roughly an order of magnitude under industry-benchmark for OSS with visible CTA. Matomo data confirms it's a discovery gap rather than a value-prop gap:/sponsors.htmlreaches only 1.18% of website visitors over the May 21 - Jun 19 window even though/installation.htmlreaches 29%, and the sponsors page itself converts fine when reached (70 s dwell, 53% bounce). The banner targets the in-app surface where the existing 9,140 monthly installation-page visitors actually live after they finish installing. Three newsponsors.*i18n keys (sectionTitle,tagline,viewSupporters) translated into all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Same release also ships a post-install ribbon between Quick Install and System Requirements on thebambuddy-websiterepo'sinstallation.html, plus a?from=install-bottomtracking param on the existing bottom CTA so the two website positions are A/B-comparable in Matomo from day one. - In-app sponsor-toast triggered at earned milestones (Prints / Cost / Archives / Anniversary / Version-update) — Companion piece to the prominent sponsor banner that shipped earlier in this release (Settings → General full-width gradient panel). Banner gives passive every-visit visibility on a single page; the toast adds opt-out-able active visibility at moments where the user has just earned something with Bambuddy. Motivation: 0.08% conversion gap. Re-baselining the install base from the ghcr.io pull counter (~10,000 pulls/day rising, captured in
bambuddy-install-base-2026-06-20.md) places active installs around 8,000-12,000 — at 8 current sponsors (per [[sponsor-portal]]) that's a 0.08% conversion rate, 6-25× under industry-benchmark for OSS with visible CTA. Matomo data over the May 21 - Jun 19 window shows only 1.18% of website visitors reach/sponsors.htmldespite 29% hitting/installation.html— the ask was discoverable on the marketing site but invisible inside the running app where users actually live. Trigger families (5). Prints: completed prints reach 100 / 500 / 1000 / 2500 / 5000. Cost: cumulative tracked filament-plus-energy cost crosses €100 / €500 / €1000 (currency-agnostic threshold — the frontend renders with the user's configured currency symbol). Archives: 50 / 250 / 1000 print archives saved. Anniversary: 1 year from the user'screated_at(auth-enabled), orMIN(users.created_at)as the install-anchor (auth-disabled, see below). Version-update: soft fallback that fires once after a major-version bump, re-armable on each subsequent bump. Priority order. When multiple families are eligible at once, the service picks in this order: anniversary → prints → archives → cost → version-update — most emotional / earned first; version-update is the unobtrusive fallback. 14-day cooldown across all families so an active power-week with stacked milestones never triggers more than once. Backed by a singlelast_shown_atcolumn on the per-user state row. Auth-disabled mode is first-class, not an afterthought. Roughly 60-70% of installs run with auth disabled (single-user home setups — exactly the local-first cohort that "Bambuddy stays free because people support it" lands hardest with). Rather than ship a half-feature for them, the state schema uses auser_id NULLABLEcolumn: in auth-enabled mode there's one row per real user; in auth-disabled mode there's a single NULL-keyed install-default row. The service evaluates exactly one code path that branches at the SQLWHERElevel (column IS NULLvscolumn = X), no doubled storage logic, no duplicated trigger code. Counter queries for prints / cost / archives useprint_log.created_by_id IS NULLfor the install-default count. Backend. NewSponsorToastStatemodel (backend/app/models/sponsor_toast_state.py) with columnsuser_id(nullable FK withON DELETE CASCADEso a deleted user takes their toast state with them),last_shown_at,milestones_seen(Text storing a JSON-serialisedlist[str]of fired milestone keys for SQLite/Postgres uniformity),last_seen_version, plus standardcreated_at/updated_attimestamps. UNIQUE constraint onuser_idso there can be at most one row per user (or exactly one NULL-keyed row). The table is created viaBase.metadata.create_all()at init — no explicit migration inrun_migrations()needed since this is a brand-new table, not an ALTER on an existing one. Service.backend/app/services/sponsor_prompt.pywith two public entry points:evaluate(db, user_id_or_None) -> Trigger | Nonewalks the five checks in priority order, returns the first eligible one or None;dismiss(db, user_id_or_None, milestone)anchors the 14-day cooldown and either appends the milestone tomilestones_seen(one-shot families) or just bumpslast_seen_version(version-update is re-armable). State row is created lazily on first access so no migration seed is required. Print-milestone selection picks the LARGEST unseen threshold the user has crossed — a user who reaches 600 prints with no prior toasts gets prints-500 (not prints-100), so the relevant milestone fires; if they've already seen prints-500, they'd fall through to prints-100 next time the cooldown lifts. Cost path sumsprint_log.cost + print_log.energy_costso the threshold reflects total spend Bambuddy has tracked, not just material. Routes.GET /api/v1/sponsor-prompt/checkreturns{show: false}or{show: true, milestone, family, threshold, payload};POST /api/v1/sponsor-prompt/dismisstakes{milestone: string}and returns 204. Both gated withPermission.SETTINGS_READviaRequirePermissionIfAuthEnabled— every authenticated user has this, and auth-disabled installs hit them withcurrent_user = Noneand the service handles that as the install-default row. Frontend hook. NewuseSponsorPrompt(currencyCode)hook (frontend/src/hooks/useSponsorPrompt.ts) fires once per browser session after auth resolves: checkssessionStorage['sponsorPromptShown']to avoid double-firing on a single session's mount/unmount cycles (Layout re-renders, navigation, etc.), then callssponsorPromptApi.check(). If a trigger comes back, builds the localised message via the newsponsors.toast*keys and displays a persistent toast with a "View supporters" CTA linking tohttps://bambuddy.cool/sponsors.html?from=app-toast-{milestone}— every milestone gets its own tracking parameter so Matomo can split-test which trigger families drive the most conversion. Click on the CTA firessponsorPromptApi.dismiss(milestone)to anchor the cooldown server-side and closes the toast. The hook is wired intoLayout.tsx(which sits inside<ProtectedRoute>so auth has already resolved) and pullssettings.currencyfrom the existing settings useQuery — no duplicate fetch. Toast extension. ExistingToastContextextended with optionalaction: { label, href, onClick }onshowPersistentToast. The non-dispatch toast renderer gets a new branch: ifactionis present, render an inline<a>styled as a small bambu-green pill before the dismiss-X. Click on the action fires itsonClick(used by the sponsor hook to call dismiss) and closes the toast. Existing showToast / showPersistentToast call sites are unaffected —actionis optional, omitting it gives the previous icon + message + X behaviour exactly. i18n. 5 templated keys in the existingsponsors.*namespace (toastPrints{{count}}/toastCost{{total}}/toastArchives{{count}}/toastAnniversary/toastVersionUpdate{{version}}) — fewer raw strings than naive per-milestone (5 × 5 + 3 + 3 + 1 + 1 = 25) but emotionally equivalent because i18next interpolates the count at render time. Real translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); no English fallback. Parity check 5214 leaves per locale. Cost messages are written so the currency symbol can be prepended client-side ({{total}}already includes the symbol) — works for USD, EUR, GBP, JPY, etc., the existinggetCurrencySymbolutil returns the right glyph fromsettings.currency. What this does NOT do. Provide an in-app opt-out toggle — the 14-day cooldown plus the "earned milestone" requirement means a typical user sees the toast 5-15 times per year, which we picked deliberately as the line between visible and naggy. If user feedback after the 2026-06-27 Matomo conversion check (see the install-base memory) shows the cadence is too aggressive we'll add a Settings → Notifications toggle then; shipping it now would dilute the "is this actually a problem worth fixing?" signal. Use plural-form i18n suffixes (_one,_other) on the count keys — the message templates are written so they read naturally at every count value (100 / 500 / 1000 are all plural in every locale, anniversary is hardcoded to "one year"), but languages with three+ plural forms (Russian, Polish, Arabic) would need this later if we ship those locales. Affect the existing Settings → General sponsor banner shipped earlier in this release — that's a passive every-visit surface and stays exactly as-is; the toast is the active milestone-based companion. Affect un-authenticated routes (login page, setup page, spoolbuddy kiosk, camera embeds) — the hook lives inside Layout which only renders inside<ProtectedRoute>. Tests. 24 new cases. 20 inbackend/tests/unit/test_sponsor_prompt_service.pycovering: empty-state no-fire (× 2), state row lazy creation, 14-day cooldown (within / past × 2), prints fires at 100 + picks-highest-unseen + skips-already-seen + failed-prints-don't-count (× 4), archives at 50, cost crosses 100 (counting only completed cost-bearing prints, not raw print count), anniversary at 370d vs 300d (× 2), version-update first-read silently anchors vs subsequent fires on bump (× 2), priority anniversary-beats-prints + prints-beats-archives (× 2), dismiss-adds-to-seen-and-anchors-cooldown + re-evaluation-returns-None, version-update-dismiss-updates-version-not-seen-list (× 2), auth-disabled uses install-anchor + null-keyed-counters-isolated-from-per-user (× 2). 4 inbackend/tests/integration/test_sponsor_prompt_api.pycovering:/checkreturns{show: false}on empty install,/dismiss422 on missing milestone,/dismiss204 on success, check-then-dismiss-then-recheck-is-silent (cooldown anchors even when the original check returnedshow: false). Frontend: existingToastContext.test.tsx,Layout.test.tsx,SettingsPage.test.tsxall green (74/74 — the action-prop extension is additive on an optional field, so existing toast tests with no action keep their previous expectations). Full backendpytest -n 306250/6250 in 64 s; ruff clean (4 import-order auto-fixes applied); ESLint clean;npm run buildclean (1.74 s); i18n parity 5214 × 11 green. - File Manager: user-authored tags for cross-cutting file filtering (#1268, requested by zumik3-del, seconded by unLieb) — Third and final piece of #1268, shipped alongside the recursive-search + markdown-description-panel changes below. Folders are the hierarchy (every file lives in exactly one); tags are the orthogonal labels ("toy", "kid-safe", "petg-only", "failed twice", "gift") and a single file can carry as many as the user wants. Reporter wanted to find "every toy regardless of which folder it lives in" — folders alone can't do that without forcing files into one bucket. Catalog model. New
library_tagstable (id, name, name_key UNIQUE =LOWER(TRIM(name)), timestamps) holds the global tag catalog — one set per install, not per-user (matches the Locations PR #1505 from earlier in 0.2.5b1).name_keyUNIQUE on a normalised key collapses "Toys" / "toys" / " TOYS " into a single row so users can't accidentally fragment the tag space by typing variations. Newlibrary_file_tags(file_id, tag_id)composite-PK association table withON DELETE CASCADEon both sides — deleting a tag drops every chip from every file (files survive); deleting a file drops its tag links (catalog rows survive). Both tables auto-create viaBase.metadata.create_all()at init — no explicitrun_migrations()step needed since they're greenfield. API. New/library/tagsrouter (backend/app/api/routes/library_tags.py) withGET(list + per-tagfile_countprojected via subquery; filtered by ownership forLIBRARY_READ_OWNusers so chip counts match what they'd actually see),POST(create — strips whitespace, 409 on case-insensitive dup with both pre-check AND post-commit IntegrityError catch for race safety),PATCH /{id}(rename, same 409 rules, self-rename allowed via id-exclusion in the pre-check),DELETE /{id}(cascade), andPOST /library/tags/bulk-assignfor multi-file ops. Bulk-assign supports three actions:add(idempotent — re-applying doesn't 409, just no-ops for pre-existing pairs, count reports what actually changed),remove, andreplace(strip everything currently on the listed files, then INSERT the new set — passing emptytag_idswithreplaceclears the file's tag set entirely). Per-file ownership enforced forLIBRARY_UPDATE_OWNusers via a pre-filter onfile_ids(silently drops files the caller can't update — same posture aslibrary_trashbulk routes; the response counts reflect what actually happened so the UI can detect partial application). Unknownfile_ids(race with a deleter, stale FE selection) are silently dropped instead of 404'ing the whole call.list_filesextension. Newtag_ids: list[int]query param on the existing/library/filesroute — repeated?tag_ids=N&tag_ids=Mstyle. AND semantics: JOIN the association,GROUP BY file.id HAVING COUNT(DISTINCT tag_id) = len(tag_ids), portable across SQLite and Postgres. Per the design discussion, the tag filter intentionally bypasses folder scoping (folder_id/project_id/include_root/recursiveare all skipped whiletag_idsis non-empty) — the whole point of tags is cross-cutting "every file matching these labels regardless of where it lives". Every file in every listing response now carriestags: list[{id, name}]viaselectinload(LibraryFile.tags)so chip rendering on the FE is N+1-free. Frontend —LibraryTagsModal. Catalog CRUD modal opened from the File Manager toolbar's new Tags button, max-w-4xl wide so multi-language subtitles don't wrap. Table with name + file count + rename/delete actions; row-click pushes the tag into the active filter and closes the modal. Delete confirm-dialog warns specifically whenfile_count > 0("This tag is on N file(s). Deleting removes the chip from all of them; files themselves are untouched."). Same Esc / backdrop / mid-mutation guard shape asLocationsModal. Frontend —BulkTagsPickerModal. Opens from the File Manager's multi-select toolbar (new Tag button between Move and Delete). Add/Remove radio at the top, scrollable checkbox list of catalog tags, inline "create new tag" affordance disabled on case-insensitive dup against the existing list, Apply button disabled until ≥1 tag is selected. Thereplaceaction is exposed in the API but deliberately NOT in the UI — arbitrary multi-file replace is destructive and confusing; future bulk-edit screen can opt in later. Frontend —FileManagerPageintegration. NewselectedTagIds: number[]state, sorted into theuseQuerykey so the cache hits are stable regardless of toggle order. Tag catalog shared with the modals via['library-tags']query key (extracted tofrontend/src/utils/libraryTagsQuery.tsto satisfy Vite's react-refresh rule that component files export only components).useEffectprunesselectedTagIdswhen a tag is deleted from the catalog so the filter never strands on a phantom id. Filter rail above the file list lists EVERY catalog tag as a togglable chip — inactive chips are outlined and muted, active chips are filled bambu-green with an X, click toggles. "Clear all" appears only when ≥1 tag is active. Hidden entirely when the catalog is empty so fresh installs don't see a stray bar. List view gets a dedicated Tags column atminmax(0, 200px)between Prints and Actions — placed after the existing data attributes since tags are a "file attribute". Empty state shows a-to keep the column shape consistent. Grid view chips render below the metadata block in each FileCard. Chip clicks in both views push toselectedTagIds; click propagation is stopped so a chip click doesn't toggle the file's selection state. Type safety.LibraryFileListItem.tags?: LibraryTagSummary[]is OPTIONAL even though the backend always emits an empty array, because legacy msw mocks in pre-existing tests (FileManagerPage / FileManagerExternalFolder) construct partial file shapes without the field — without the?the renderer crashed on.length. Read sites usefile.tags ?? []and the!.non-null assertion only inside the inner&&guard. Dependencies. Zero new deps. The whole tag UI reuseslucide-react'sTagicon, existing button/modal primitives, andtanstack/react-queryalready in the bundle. Bundle size unchanged from the previous 0.2.5b1 baseline (7,876 KB raw / 2,122 KB gzip). Tests. 15 backend integration cases inbackend/tests/integration/test_library_tags_api.py— CRUD: create + list, strip-whitespace, case-insensitive dup 409 across "Toys"/"toys"/"TOYS"/" ToYs ", rename, rename-collision 409, self-rename allowed, delete cascades associations but keeps files, delete-unknown 404. Bulk: add idempotency (second call adds 0, file_count stays 1), remove drops only listed tags (peer tag stays), replace-with-empty clears, unknown file ids silently skipped, invalid action 422. Filter: AND across two tags returns only the intersection file, tag filter overrides folder_id (file from another folder still appears when the tag matches), file listing includes the tags array. 15/15 green plus 102/102 acrosstest_library_api.py+test_library_trash_api.py(no regression). 8 frontend cases — 4 inLibraryTagsModal.test.tsx(renders + count, create flow PATCHes correctly, row click → onPickTag + close, in-use delete warning), 4 inBulkTagsPickerModal.test.tsx(lists tags, check + Add calls bulkAssign with action='add' and the right file/tag arrays, Remove radio + Apply uses action='remove', Apply disabled when no tag selected). Full vitest run: 2249/2249 across 170 test files. Full backendpytest -n 30: 6341/6341. i18n. 37 new keys underfileManager.tags.*namespace (modal title/subtitle, manage/manageTitle, add/edit, name/fileCount, empty/noMatches, createPlaceholder/createButton, nameRequired, searchPlaceholder, CRUD success/failure toasts, applyAdd/applyRemove + their success messages, actionAdd/actionRemove radio labels, tagAction button, bulkTitle, bulkTooltip, noPermission, filterLabel, clearAll, confirmDelete + the in-use variant, editAria/deleteAria). Translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5292 leaves per locale, no English fallback (thedefaultValue: "..."shortcut from a first-draft modal was removed precisely so the parity check would fail loudly if any locale missed a key). Permissions. Catalog mutations requireLIBRARY_UPDATE_ALL(the catalog is global — ownership-aware update isn't meaningful for a row no user owns). Bulk-assign uses the existingLIBRARY_UPDATE_ALL/LIBRARY_UPDATE_OWNownership pair. GET usesLIBRARY_READ_*with the file-count projection narrowed for*_OWNcallers. No new permission constants, no new RBAC migration. Out of scope (deferred to v2 if asked). Tag colors / icons (label-only chips per design decision #1), tags onprint_archivesrows (different mental model — archives are completed prints), auto-tags derived from 3MF metadata categories (kept user-authored per design decision #4), import/export of the tag set, tag-filter intersected with folder scoping (the design call was that cross-cutting filter overrides folder selection — adding an "AND folder" toggle would need separate UX work). Closes #1268 alongside the recursive-search and markdown-description-panel pieces below — all three deliverables in this issue ship in the same minor. - File Manager: recursive search and per-folder markdown description panel (#1268, requested by zumik3-del, second by unLieb) — Two of the three asks bundled in #1268; the third (tags) is gated on the community-interest check Martin posted there. (1) Recursive search inside the selected folder. Until now, picking "Toys" in the sidebar and typing
robotonly found files inToys/itself — anything underToys/Cars/orToys/Cars/Race/was invisible until the user manually drilled in. The page's client-side filter was running over a server-narrowed list (/library/files?folder_id=Xis strict equality onfolder_id), so search couldn't see what the listing didn't load. Newrecursive=truequery param on/library/fileswalks thelibrary_folders.parent_idtree via a recursive CTE rooted at the requestedfolder_idand returns every descendant folder's files in one round-trip. Recursive CTEs work on both SQLite (≥3.8.3, shipped 2014 — Bambuddy's floor is well above that) and Postgres without dialect branching. Default off so the existing folder-browsing call sites (Project / Archive detail pages, the FE's no-search case) keep their narrow single-folder semantics — only the FE's search bar opts in, and only when both a folder is selected ANDsearchQuery.trim()is non-empty. A small "Including subfolders" hint renders under the search input when the recursive request is active so the user understands why a file from two folders away showed up. (2) Per-folder markdown description panel. New endpointGET /library/folders/{folder_id}/readmereads the first.mdfile in the folder and returns{filename, content, truncated}. Selection prefersREADME.md/readme.md/description.md(case-insensitive — picked viafunc.lower(filename) LIKE '%.md'filter + an in-Python stem-preference sort), falls back to the alphabetically-first*.mdotherwise. 404 when no markdown file is present so the FE can hide the side panel — non-users pay no UI cost. Bytes are clipped at 512 KiB (_README_BYTES_CAP) with atruncatedflag so the panel can warn the reader; UTF-8 decode useserrors="replace"so one bad byte never blanks the panel. NewFolderReadmePanel.tsxcomponent fetches the README on folder-select, renders it viareact-markdown9+remark-gfm4(tables / strikethrough / task lists), collapsible (default expanded), max-height 24rem with internal scroll. react-markdown 9 doesn't render raw HTML by default — XSS safe without dompurify. Links open in a new tab withrel="noopener noreferrer". Tailwind has no typography plugin in this project so per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table/etc. to explicit utility classes that match the rest of the app's look. Both ask 1 and 2 ship as one PR because they share scope (file-manager UX), the same reporter, and the same review surface; ask 3 (tags) is held back as gated on the public interest signal Martin requested in his comment ("If you'd find this feature useful, please give this issue a thumbs up"). Backend.list_filesroute atbackend/app/api/routes/library.py:1729+gains therecursive: bool = Falseparam + the recursive-CTE branch. Newget_folder_readmeroute at:1042+with_README_BYTES_CAPconstant +_README_PREFERRED_STEMSselection tuple. NewFolderReadmeResponseschema inbackend/app/schemas/library.py:66+. Frontend.api.getLibraryFilesatfrontend/src/api/client.ts:5785+gains therecursive = falseparameter;api.getLibraryFolderReadmeis the matching helper for the new endpoint.FileManagerPage.tsxderivessearchExpandsSubfoldersfromselectedFolderId !== null && searchQuery.trim().length > 0and threads it into both theuseQuerykey (so toggling search refetches with the new scope) and the API call. The newFolderReadmePanelmounts above the file list whenselectedFolderId !== null. Dependencies.react-markdown ^9+remark-gfm ^4added tofrontend/package.json(~30 KB gzipped — single use-site for now, but reusable for any future markdown surface — print-archive notes, custom-field docs, etc.). No new backend dependency. Tests. 6 backend integration cases inbackend/tests/integration/test_library_api.pypin the contract:recursive=truewalks a three-level tree and returns files from all levels but NOT a sibling unrelated branch;recursive=truewithoutfolder_idis a no-op (the existinginclude_rootbranch still handles scoping); README endpoint returns the first .md with the correct on-disk content; README endpoint prefersREADME.mdovernotes.mdeven whennotes.mdis inserted FIRST andreadme.mdis lowercase; 404 when the folder has no .md; 404 when the folder doesn't exist. 3 frontend cases insrc/__tests__/components/FolderReadmePanel.test.tsxcover: 404 hides the panel (no leaked chrome), markdown content renders viafindByRole('heading'), truncated flag surfaces a chip. Full backendpytest -n 306326/6326 green; frontend vitest 1094/1094 component cases green; ruff clean;npm run buildclean. i18n. 2 new keys —fileManager.searchSubfoldersHint(the small under-search caption) +fileManager.readme.truncated(the chip label when the markdown was clipped). Translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check 5255 leaves per locale, no English fallback. Scope. No new permission — both endpoints reuse the existingLIBRARY_READ_ALL/LIBRARY_READ_OWNownership-aware permission pair (so a viewer-tier user withread_ownonly sees their own files in recursive listings + can only request the README of folders containing their own files). No DB migration. The recursive CTE is a single SQL query — no N+1, no per-folder round-trip, scales to deeply-nested model libraries. - By-tag spool lookup, readable with a Manage-Inventory API key (#1700 closing #1663, reported + contributed by bambuman) — Companion to the QR-code-API-key flow below: gives bambuman's BambuMan NFC inventory app — and any future scanner-driven Bambuddy integration — a way to dedupe a spool scan with a single, narrowly-scoped API key. New endpoint:
GET /inventory/spools/by-tag?tray_uuid=…&tag_uid=…&include_archived=false.tray_uuidis the primary identifier (it's the same 32-char hex the AMS reports over MQTT, so the scan can match a spool that's already linked to the printer),tag_uidis the fallback. At least one must be supplied (400 otherwise); 404 when nothing matches. Both values are passed throughnormalize_tray_uuid/normalize_tag_uidfrombackend/app/utils/tag_normalization.py— lowercase / colon / dash separators all match the stored uppercase hex, mirroring the existinglink_tagroute'sfunc.upper(column) == valuecomparison so SQLite and Postgres behave identically. Archived spools are excluded by default, opt in viainclude_archived=true. Why this isn't on the existing/inventory/spoolslist endpoint: that one is purely advisory — it returns every spool the caller is allowed to see, no auth narrowing possible. The contributor's NFC app would have had to pull the whole inventory to check whether a freshly-scanned tag already existed, which both required the broader Read Status scope (an API key with Manage Inventory alone — the documented kiosk/inventory-write scope — couldn't list spools) and grew O(n) with the user's spool count. By-tag lookup is O(1) and the narrower scope rule below means the Manage-Inventory key the app already needs to create a spool is also enough to check whether one exists before creating. Scope shape (per-endpoint, NOT a global mapping change):RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE)— INVENTORY_READ is satisfied bycan_read_status(read-status keys), INVENTORY_UPDATE bycan_manage_inventory(manage-inventory keys), and_check_apikey_permissions(..., require_any=True)enforces that at least one mapped flag is set (the GHSA-r2qv-8222-hqg3 fail-closed rule). Listing all spools (/inventory/spools) and fetching by id (/inventory/spools/{id}) still require Read Status unchanged — only this one endpoint accepts either scope. The first iteration of the PR widened the global_APIKEY_SCOPE_BY_PERMISSIONto a tuple, which would have promoted ~21 inventory-read endpoints to also accept manage-inventory keys; review caught that the global shape was wider than the ask and the contributor revised to the per-endpoint dependency. The drift-detection RBAC scope-introspection tests stay untouched because the global table didn't change. Route ordering: the new/spools/by-tagregisters atinventory.py:1184before the existing/spools/{spool_id}at:1227, so FastAPI's first-match wins and the literalby-tagpath never collides with theint spool_idroute (pinned bytest_does_not_collide_with_spool_id_route). Tests: 13 integration cases inbackend/tests/integration/test_spool_by_tag_lookup.py— match by tray_uuid, match by tag_uid, normalisation of messy input, tray_uuid-preferred-when-both-given, tray_uuid-miss falls through to tag_uid (not 404), no-id → 400, non-hex → 400, no-match → 404, archived-excluded-by-default + include-archived opt-in, route-collision regression, plus three API-key scope cases that pin the new dependency (manage-inventory key reads, read-status key reads, key without either inventory scope gets 403). 13/13 green plus the 48 existing route-auth-coverage + RBAC tests still green (therequire_substring pattern already catchesrequire_any_permission_if_auth_enabled.<locals>.checker— no allowlist edit needed). Ruff clean. Companion docs (maziggy/bambuddy-wiki#42):docs/reference/api.mdgains a new Spool Inventory section documenting the endpoint contract;docs/features/api-keys.mdadds the by-tag row to the Common Endpoints table and a "Manage Inventory keys can look up spools by tag" note. No DB migration, no schema change, no frontend change. - QR code on API-key creation that encodes server URL + key together (#1677, contributed by bambuman) — The "API Key Created Successfully" panel gets a new QR code button next to Dismiss. Clicking it opens a modal showing a single QR encoding the Bambuddy base URL and the freshly-created API key together, so a mobile client (e.g. the contributor's BambuMan NFC inventory app, or any future Bambuddy-aware app) can scan once to configure both — no copy-paste of the long, shown-only-once secret. Payload contract (versioned):
bambuddy://config?v=1&url=<encodeURIComponent(baseUrl)>&key=<encodeURIComponent(apiKey)>.v=1first so future bumps tov=2have a clean deprecation path; both values URL-encoded so reserved characters in either don't corrupt the parse. The builder lives infrontend/src/utils/apiKeyQr.tsexportingbuildApiKeyQrPayload()+API_KEY_QR_VERSIONso any future mobile-side parser has a stable shared constant to anchor against.baseUrlsource: prefers the configured External URL setting (Settings → Network), falling back towindow.location.originif not set, so the encoded address is reachable from a phone behind a reverse proxy / Docker host. The fallback's failure mode (admin onhttp://localhost:8000without External URL configured → phone can't reach the encoded URL) is unavoidable without exposing a network probe; the warning text in the modal cautions the user generally. Security posture: the QR is generated client-side from the in-memorycreatedAPIKeyReact state — the key is never persisted, never re-fetched (keys are stored hashed at/api/keysPOST and returned in plaintext exactly once), and never round-trips to the server. No download button (intentional contrast with the existingQRCodeModal.tsx, which encodes a public archive URL and does offer download) so the secret can't be saved to disk via the browser's download manager. The "Dismiss" handler now clears bothshowApiKeyQRandcreatedAPIKeyso closing the panel scrubs the plaintext from React state. Modal closes on Escape and backdrop click; an amber warning under the QR reminds the user not to screenshot or share. Component: newfrontend/src/components/ApiKeyQRCodeModal.tsxusingqrcode.react'sQRCodeSVGat 256 px (renders Version 5 / 6 territory for the typical ~120-character payload, comfortably below the alphanumeric capacity). Dependency:qrcode.react ^4.2.0added tofrontend/package.json(+21 KB raw / ~9 KB gzip to the bundle). Existingfrontend/src/components/QRCodeModal.tsxis untouched — different purpose (server-rendered PNG for archive deeplinks), different component, no collision. Tests:frontend/src/__tests__/utils/apiKeyQr.test.tspins the contract — scheme +v=first, exact encoding ofhttps://printer.local+bb_abc123byte-for-byte, special-character round-trip (+,/,=,&, spaces), explicit assertion that the raw unencoded key never leaks into the payload, and aURLSearchParamsround-trip that re-parsesv/url/keyback out and asserts equality with the inputs. 4/4 green. i18n: 4 new keys in thesettings.*namespace (apiKeyQrButton,apiKeyQrTitle,apiKeyQrCaption,apiKeyQrWarning); full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check green. ESLint clean;npm run buildclean (7,603 kB raw, +21 kB vs dev). No backend change, no permission change, no DB migration. - Centralised sidebar layout + per-page hide toggles (#1673, contributed by EdwardChamberlain) — Sidebar item ordering and visibility move from inline
Layout.tsxstate to a dedicated module so the same persistence rules apply whether the user is reordering with drag-and-drop, toggling an item off, or accepting the admin-pushed default. Newfrontend/src/utils/sidebarLayout.tsowns the localStorage round-trip (sidebarOrder+sidebarHiddenSystemItemskeys), theSIDEBAR_LAYOUT_CHANGED_EVENTcross-tab refresh broadcast, and theisExternalSidebarItemIdhelper that distinguishes the newext-*external link prefix from built-in nav. Hide / show toggle: every built-in sidebar entry (Printers / Inventory / Archives / Queue / Projects / File Manager / Makerworld / Profiles / Maintenance / Statistics — Settings is intentionally non-hideable) now carries an eye icon in the Sidebar settings card; click it to drop that entry from the rendered sidebar. Hidden IDs persist per-user via localStorage so personal taste survives reloads without leaking to other users on a shared install. Re-show by clicking the eye again. The previous drag-to-reorder UX is retired in this PR — the hide list + admin default order cover the same "I never use the Stats page" / "give me Files first" needs without the affordance ambiguity of the rearrange handle. Admin default order: newdefault_sidebar_ordersetting (validated server-side atbackend/app/schemas/settings.py:533+) holds a JSON object{order: string[], hiddenSystemItemIds: string[]}that admins set once from Settings → General → Sidebar (Set Default toggle). On first login per user,Layout.tsx'suseEffectreads the admin default, filters it against the currentdefaultNavItems+ valid external IDs (so a deleted external link or a removed built-in doesn't strand in someone's stored order), applies it locally, and records a per-usersidebarDefaultApplied_<user_id>localStorage flag so the default is one-shot — later user-driven changes aren't clobbered on every login. Settings card:ExternalLinksSettings.tsxis the single source of truth for the Sidebar card (card-sidebar-links) in Settings → General. The header now carries the Set Default toggle (visible only when the caller holdssettings:write), a Reset button (clears bothsidebarOrder+sidebarHiddenSystemItemsto defaults), and the Add Link button (opens the external-link create modal). The body lists every sidebar item — built-in or external — with the eye toggle inline on each row. The header row usesflex-wrapon the outer container and the right-side control group so the Add Link button doesn't overflow the card's right edge when Column 3 sits at its narrowlg:max-w-sm(384px) width. Settings → General reordering (post-merge polish): the Updates card moved to the top of Column 3 (above the new Sidebar card); the Data Management card moved to the bottom of Column 2 (after Library Auto-Purge) so the General tab balances better with the new Sidebar card taking column 3's vertical real estate. Anchor IDscard-updates,card-data,card-sidebar-linksare preserved so deep-links + the in-appregisterSettingsSearchindex still resolve. Layout merge edge case: the PR's refactor ofLayout.tsx::isHiddenaccidentally dropped the dev-side notifications gate (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || settings?.user_notifications_enabled === false) and itsadvancedAuthStatususeQuery. The merged shape keeps three gates in priority order —hiddenSystemItemIds.includes(id)first (cheapest, explicit user intent), then the array-awarenavPermissionscheck from #1755 (granular*:read_own/*:read_alltiers), then the notifications-specific gate — so a user without advanced auth doesn't suddenly see the Notifications entry. Backend:default_sidebar_ordersettings field accepts both shapes (plain array OR{order, hiddenSystemItemIds}object) for backward compat with installs that saved an array under an earlier draft of this work. Validator rejects anyhiddenSystemItemIdsthat isn't alist[str]with 422. Tests: 17 new backend cases intest_sidebar_settings.pypinning the validator (empty / JSON-array / JSON-object / mixed-types / hostile shapes). Frontend: 5 newLayout.test.tsxcases pinning the hide-toggle behaviour (hidden ID drops the entry, hidden ID for Settings is ignored —settingsis non-hideable, eye-click round-trips through localStorage,SIDEBAR_LAYOUT_CHANGED_EVENTtriggers a re-read across tabs) and 255 added/changed lines inSettingsPage.test.tsxcovering the admin-default toggle and the eye-icon visibility column. i18n: new keys in theexternalLinks.*namespace (sidebarLayout / sidebarLayoutDescription / visibleInSidebar / hiddenFromSidebar / requiredInSidebar / setDefault / etc.), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Vitest test timeout raised invitest.config.tsto absorb theuserEvent.setup({delay: null})cases in the heavierSettingsPageflows. Full vitest run green; ESLint clean;npm run buildclean; ruff clean. - Structured storage locations catalog (#1505 closing #1004, contributed by Poltavtcev) — Inventory gets a first-class catalog of physical storage spots (shelves, drawers, dryboxes) instead of free-text in the spool's
storage_locationfield. Spools now carry alocation_idFK alongside the denormalizedstorage_locationstring (kept for Spoolman wire format + label rendering). The Inventory page picks up a Locations button that opens an in-page modal — the original PR landed a standalone/inventory/locationspage; merged shape is a modal opened from Inventory so the catalog read sits next to the spool list. The modal handles create / edit / delete / pick-to-filter; row-click pushes the location_id into the Inventory filter state without a navigation. Deep-link?location_id=<n>(and?location_id=__none__for the unset bucket) still works for sharing or bookmarking. Backend: newLocationmodel +locationstable with case-insensitivename_key(LOWER(TRIM(name))) UNIQUE — concurrent creates on the same name resolve to a single 409 via theIntegrityError→ re-fetch shape in_create_location_or_get_existing. CRUD at/api/v1/inventory/locations, all five routes gated withRequirePermissionIfAuthEnabled(Permission.INVENTORY_READ|UPDATE). Delete is blocked whilespool_count > 0so the user can't strand spools. Single-write-path islocation_service::resolve_spool_location_fields()— both the internal-mode and Spoolman-mode spool routes feed through it solocation_idandstorage_locationcan never drift. Spoolman parity: location names sync into the local catalog onGET /spoolman/inventory/spoolsviamaybe_sync_spoolman_locations; rename cascades to every Spoolman spool viaclient.rename_location, with a per-spool PATCH fallback when the upstream's bulk endpoint isn't there (Spoolman <0.16 doesn't exposePATCH /location/{name}and returns 404/405).get_distinct_locationsnormalises both the olderlist[str]and the newerlist[dict]Spoolman payload shapes. Migration: inline indatabase.py::run_migrations— creates thelocationstable (DATETIME for SQLite / TIMESTAMP for Postgres), addsspool.location_idFK + index, then backfills the catalog from existing free-text values (GROUP BYLOWER(TRIM(storage_location))so case variants likeDrybox 1andDRYBOX 1collapse into one row). The legacyname_keybackfill runs BEFORE the dedup INSERT so a pre-existing locations row with NULLname_key(manually inserted before this feature shipped) gets its column populated first and the subsequent spool-link UPDATE can join on it. Post-migration warn-log flags any spools that still carry free-textstorage_locationwith nolocation_id— surfaces the rare mis-link case to ops instead of silently leaving them out of catalog filters. Rename safety: Spoolman PATCH runs BEFOREdb.commit(), cascade failure rolls back the local rename and raises HTTP 502 — without this ordering a partial failure left the catalog and Spoolman's per-spoollocationfield permanently diverged (the next sync recreates the old name as a duplicate catalog row). Legacy-row UPDATE matchesfunc.lower(func.trim(Spool.storage_location)) == old_name.strip().lower()so the SQL TRIM symmetry holds for whitespace-padded values. Cross-tab refresh:spoolman_inventory.pynow emitsinventory_changedon the 8 spool-mutating routes (create, bulk-create, update, delete, archive, restore, reset-bulk, weight, tag) — internal mode already broadcast in 12 places, Spoolman mode silently degraded before. TheuseWebSockethandler invalidatesinventoryLocationsQueryKeyon every such message so location counts stay in sync across tabs. Performance: the Spoolman→catalog sync used to fire on everyGET /spoolsrequest, hit Spoolman, and open a write transaction; now guarded by a 60s per-URL TTL cache (_spoolman_location_sync_last_run) so a polling UI doesn't burn a Spoolman round-trip + SQLite write per refetch. The route also passes its already-resolved client through to the sync so test fixtures that patch the route module's client also catch the sync's client lookup — without this the SSRF LAN-topology parametrize tests took ~45s on real TCP timeouts to RFC-1918 IPs (now 2.79s in isolation). Frontend:SpoolFormModallocation dropdown sendslocation_idonly (same shape in both inventory modes — nospoolmanMode ? ... : ...UI gate) and theonCreateLocationflow surfacesApiError.messageinstead of a generic toast so 409 / 400 / 500 stay distinguishable.LocationsModalpassesisLoadingtoConfirmModalduring delete so a mid-mutation cancel can't strand a toast on a dismissed dialog; Pencil / Trash icon buttons carryaria-labelfor SR announcement. i18n: newlocations.*namespace (20 keys: title, subtitle, add, edit, delete, empty, name, spools, manage, createPlaceholder, nameRequired, created, updated, deleted, saveFailed, deleteFailed, deleteBlocked, confirmDelete, confirmDeleteMessage, editAria/deleteAria), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Tests: ~26 new acrossbackend/tests/unit/test_location_service.py(rename strip/lower symmetry, sync-from-Spoolman log-on-unavailable, list[dict] payload normalisation),backend/tests/unit/test_spoolman_inventory_methods.py(get_distinct_locationsshape guard × 4,rename_locationbulk-then-fallback × 4 — 200 / 404 / 405 / 5xx),backend/tests/unit/test_location_migration.py(NULL + whitespace-only storage_location skip, legacy NULL name_key ordering, case-variant dedup, idempotency),backend/tests/integration/test_locations_api.py(CRUD round-trip, rename cascade, IntegrityError → 409, PATCH/DELETE 404, auth-gate 401 on all five routes whenauth_enabled=true), andfrontend/src/__tests__/components/LocationsModal.test.tsx(12 cases: open=false renders nothing + no fetch, row click → onPickLocation + onClose, 2-level Escape dialog stacking, rename collision 409 toast, disabled delete onspool_count>0, etc.). FrontenduseWebSocket.test.tsexercises theinventory_changed→ invalidate['inventory-locations']round-trip. Full backend pytest 6025/6025 (67s with -n 30); frontend vitest 2141/2141; ruff clean;npm run buildclean; ESLint clean; i18n parity green. - Admin-configurable session lifetime (#1706, reported by AD3DStuff) — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. New setting:
session_max_hoursunder Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." Backend wiring: newresolve_session_max_minutes(db)helper inbackend/app/core/auth.pyreads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain/auth/login, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depthSESSION_MAX_HOURS_HARD_CEILING = 720clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. What this does NOT change: the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (MAX_TOKEN_LIFETIME_DAYS), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. Tests: 15 new cases inbackend/tests/integration/test_session_policy.pysplit across three classes.TestResolveSessionMaxMinutespins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp.TestLoginRespectsSessionPolicydecodes the JWTexpclaim end-to-end and asserts the token returned by/auth/loginhonours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases.TestSettingsAPIExposesSessionMaxHoursround-trips the field through/settings/(default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. i18n: 8 new keys insettings.sessionPolicy.*namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean;npm run buildclean; ruff clean. - Per-VP "G-code injection" toggle for Studio Send / FTP uploads (#1516, contributed by phieb) — Queue-mode Virtual Printers gain a per-VP opt-in toggle that applies the Settings → G-code Snippets per-model start/end snippets to every job that lands via the VP — Bambu Studio's "Send", OrcaSlicer's "Print Plate", the VP's own FTP upload path. Before this change the snippets were only applied to items queued through the PrintModal's "Inject auto-print G-code" checkbox; VP-incoming jobs silently bypassed injection regardless of how the snippets were configured. Default off so upgraders don't silently start injecting: existing
gcode_snippetsinstalls keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unlessgcode_snippetsare configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." DB column: newvirtual_printers.gcode_injection BOOLEAN DEFAULT FALSEwith a branchedis_sqlite()migration (SQLiteDEFAULT 0/ PostgresDEFAULT FALSE) matching thequeue_force_color_match/tailscale_disabledprecedent. Multi-plate stamping: the flag is set on every plate'sPrintQueueIteminside the per-plate loop introduced by #1697 / #1188, so a multi-plate "Send all" upload now gets snippets injected on each plate consistently — the original PR only stamped the first plate; the merge resolution wove the flag into the loop. Live-toggle correctness: the_sync_from_db_lockedchange detector now comparesinstance.gcode_injection != vp.gcode_injection, so toggling the value in the UI triggers a VP restart instead of letting the in-memory instance keep the stale flag and silently propagate it onto every subsequent upload — same shape as the #1552 family. Backed by a dedicatedtest_sync_from_db_restarts_on_gcode_injection_toggle. UI: new toggle onVirtualPrinterCard.tsx(queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standardupdateMutationsave-on-click + toast on success, plus thependingAction='gcodeInjection'opacity dim during the round-trip. PrintModal hardening: when "Inject auto-print G-code" is ticked on a reprint at quantity > 1, the modal now routes ALL copies through the queue (not just copies 2..N) so the scheduler injects every dispatch — see the separate reprint-quantity entry below for the full motivation. A newuseEffectclears the stalegcodeInjectionstate if the user ticks the box at quantity 2, then drops back to quantity 1 — the checkbox hides at that point and the state must follow, otherwise the immediate-reprint path would silently bypass injection. Diagnostics: the resolved start/end snippets (with{placeholder}substitution already applied) are logged at DEBUG so any "snippet didn't run" report can be traced from a log bundle. i18n: newvirtualPrinter.gcodeInjection.title+descriptionkeys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5188 leaves per locale, no English fallback. Tests: 2 new unit cases intest_virtual_printer.py(queue items opt in / out based on the VP flag), 2 new integration cases intest_virtual_printer_api.py(create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to_make_db_vpso the change-detector test fixture carries an explicitFalserather than relying onMagicMocktruthiness. 2 new PrintModal vitest cases pin the reprint dispatch matrix (injection ON queues all copies and dispatches none immediately; injection OFF keeps the immediate first copy and queues the rest). Full backend pytest 6167/6167; full frontend vitest 2154/2154; ruff clean;npm run buildclean. - Heater history (nozzle / bed / chamber) tracked + charted, matching AMS sensor history — Bambuddy already logged AMS humidity / temperature every 5 minutes and exposed them in a per-AMS history modal; the printer-side heaters (nozzle, optional second nozzle on H2D, bed, chamber) had no equivalent surface. New per-printer recorder logs heater readings every 60 s (heaters move faster than AMS humidity) into a new
printer_sensor_historytable — long format per(printer_id, sensor_kind, value, target, recorded_at)withsensor_kind ∈ {nozzle, nozzle_2, bed, chamber}so model variants (single vs dual nozzle, sensor-only X1C/P2S chamber vs heater-equipped X1E/H2D chamber) all fit without a wide-row migration when new sensors get added later. Recorder source. Pulls fromstate.temperatures(already normalised across model field-aliases likenozzle_temper,left_nozzle_temper,right_nozzle_temper,chamber_temperby the MQTT parser) rather than re-parsingraw_data, so cross-model coverage is free. Skips disconnected printers; absent sensors (no chamber on A1 / A1 Mini) just don't produce rows for that kind. Retention. Newprinter_sensor_history_retention_dayssetting (default 30, mirroring the existingams_history_retention_daysknob); periodic cleanup fires once every ~24 h within the recorder loop and trims rows older than the configured cutoff. API.GET /printer-sensor-history/{printer_id}?hours=<1-168>&kinds=<csv>returns one series per requested kind withdata: [{recorded_at, value, target}, ...]+ min / max / avg stats per series.DELETE /printer-sensor-history/{printer_id}?days=<N>for explicit purge. Both gated behind the new explicitPRINTER_SENSOR_HISTORY_READscope (separate fromAMS_HISTORY_READso admins can grant heater stats without granting humidity stats and vice versa — both default into the Operators + Viewers groups, mirroring the existing AMS gate). UI surface — tiny chart icon on each heater tile. Each heater tile in the printer card (nozzle / bed / chamber) gets a 10×10 px lucideLineCharticon button in its top-right corner. Click on the tile body still opens the existing target-temp control popover (unchanged); click on the icon opens the newHeaterHistoryModalwith that kind pre-selected ande.stopPropagation()so the underlying control popover doesn't also fire. The read-only chamber tile (X1C / X1E / P2S — sensor-only, no M141 acceptance) gets the icon overlay too, so for the first time it has a useful interaction beyond just showing the reading. Modal shape. Samevar(--bg-*)/var(--text-*)theme variables asAMSHistoryModalso it follows every background variant (neutral / warm / cool / oled / slate / forest), not just light vs dark. Kind toggle (nozzle/nozzle_2/bed/chamber, filtered by what's actually available on this printer) at top-left, time range (6h / 24h / 48h / 7d) at top-right, four stat cards (current with trend arrow + target value, average, min, max), and a rechartsLineChartplotting current value as a solid line plus target as a dashed step-after line so you can see where the printer was tracking vs where it was asked to be. Empty / loading / error states all localised. i18n. 8 new keys inprinters.heaterHistory.*(title,openLabel,nozzle,nozzle2,bed,chamber,error,empty) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5206 leaves per locale, no English fallback. Tests. 4 new backend cases intest_printer_sensor_history.py(per-sensor series + stats;kindsquery filter;hourswindow clamp; DELETE removes only the targeted printer's old rows). 6 new frontend cases inHeaterHistoryModal.test.tsx(closed → renders nothing; open → title + printer name; current value from last point; switch kind with button; empty-series state; close button onClose). Full backendpytest -n 306226/6226 in 92 s; full frontend vitest 2176/2176; ruff clean;npm run buildclean; ESLint clean. - AMS Filament Backup status + control on the printer card — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). Backend — parse the state. New tri-state
PrinterState.ams_filament_backup: bool | Nonepopulated from bit 18 of the top-levelprint.cfghex string on every push_status (bambu_mqtt.py::_process_message~line 1037). New module-level helperparse_ams_filament_backup_from_cfg()returnsNoneon absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit nocfg) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (DeviceManager.cpp:4961SetAutoRefillEnabled(get_flag_bits(cfg, 18))) and a live H2D ON/OFF capture during this work — the cfg flips exactly betweenC0340FC219(bit 18 set, ON) andC0340BC219(bit 18 clear, OFF), only the fifth nibble changing. Backend — toggle. NewPOST /printers/{id}/ams-backup?enabled=<bool>route gated onPermission.PRINTERS_CONTROLcallsclient.set_ams_filament_backup(enabled)which routes through_set_print_option("auto_switch_filament", enabled). The MQTT payload shape{"print": {"command": "print_option", "auto_switch_filament": <bool>, "sequence_id": "20000"}}was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with otherprint_optionflags, so we never clobber other state. Optimistic local state update lives inside_set_print_optionimmediately after_client.publish(...). Hold-timer guard (_xcam_hold_start["print_option_auto_switch_filament"], 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. Backend — inventory-remain endpoint. NewGET /printers/{id}/inventory-remainroute exposes the sameMap<global_tray_id, grams>the dispatcher uses (via the existing_build_inventory_remain_overrideshelper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped tomax(0.0, label - used). JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission:Permission.PRINTERS_READ(same as reading printer status). REST + WS response surface.printer_state_to_dictand thePrinterStatusResponsePydantic schema both extended with the new field; the printer's REST/printers/{id}response carriesams_filament_backup.state.ams_filament_backupadded to thestatus_keydedup tuple inmain.py:1101so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. Frontend — printer card badge. Small icon button in the "Filaments" section header on each printer card (PrintersPage.tsx), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (Repeatfrom lucide-react) onbg-blue-500/20; OFF = dim icon onbg-bambu-dark; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (AMS Filament Backup enabled/disabled). The mutation invalidates BOTH'printerStatus'(camelCase) and'printer-status'(kebab-case) cache keys — the codebase has both conventions in active use (useFilamentMapping-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. i18n. 5 new keys in theprinters.amsBackup.*namespace (titleOn,titleOff,titleUnknown,toastEnabled,toastDisabled) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Tests. 12 new backend cases —test_bambu_mqtt_cfg_parse.py(parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) andtest_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer(× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs intest_printer_offline_notification.pyandtest_printer_manager_status_broadcast.pyextended withams_filament_backup=Noneto match the newstatus_keyfield; full pytest confirms no other stub needed updating. What this does NOT do. Cover A1 / A1 Mini: those models emit nocfgfield in push_status so the badge shows?and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState:mqtt_relay, webhook routes, and Home Assistant integration enumerate fields explicitly, so addingams_filament_backupdoesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean;npm run buildclean; ESLint clean. - Sort File Manager folder tree by recent activity (#1770, requested by Kingbuzz0) — Until now the folder tree was always sorted alphabetically by name, both backend (
order_by(LibraryFolder.name)) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. What changed. The folder sidebar header gains a small dropdown (By name / By recent activity) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser vialocalStorage(library-folder-sort-field,library-folder-sort-direction) so the preference survives reloads. Activity semantics.latest_activity_atper folder =MAX(folder.updated_at, MAX(immediate-child file.updated_at)). The DB had the data —LibraryFile.updated_atisonupdate=func.now()andLibraryFolder.updated_atthe same — butLibraryFolder.updated_atalone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally NOT computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a singleGROUP BYrather than a recursive CTE, matching the existing file_counts subquery shape sibling atlibrary.py:746. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. Backend. Newlatest_activity_at: datetime | Nonefield onFolderResponseandFolderTreeItemschemas. The/folderstree route picks up a siblingfunc.max(LibraryFile.updated_at)group-by alongside the existing file-count subquery; resolves the field per row. The/folders/by-project/{id}and/folders/by-archive/{id}routes collapse their per-row file-count subquery to fetchcount + maxin one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST/folders, GET/folders/{id}, PUT/folders/{id}, POST/folders/external, the create flows) populate the field withmax(folder.updated_at, latest_file)or fall back tofolder.updated_atwhen there are no files, so the API surface is consistent across every route that returns a folder. External folders.LibraryFilerows are created for scanned external files too (library.py:526), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last scanned / re-indexed the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over withos.stat()on every list call, which would stall the route on slow mounts. Frontend. A new recursivesortedFoldersuseMemoapplies the comparator uniformly to top-level + every nestedchildrenlevel so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consumesortedFoldersso the order is identical across breakpoints. The single-folderfindFolder()traversal andselectedFoldermemo still operate on the unsortedfoldersbecause they index by ID — sort-order-independent. Recursion safety. The sort creates fresh object refs at every level on every memo invocation; theFolderTreeItemkeys stay ID-based (${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}) so React reconciliation by ID preserves folder expansion state across sort flips. i18n. 3 new keys infileManager.*(folderSort,folderSortByName,folderSortByActivity) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. Tests. 2 new backend integration cases intest_library_api.py(file-in-folder bubbleslatest_activity_atto the file's timestamp, empty folder falls back tofolder.updated_at). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass;npm run buildclean;ruffclean; i18n parity green. - Drag-reorder for grouped queue items + collapsed-batch no longer blocks adjacent rows — Print queue batch grouping (multi-plate auto-batch + manual "Group as batch") shipped with an intentional v1 limitation: the batch parent row had no drag handle and wasn't registered with the SortableContext, so a batch was stuck at whatever position it was created at. Worse, a collapsed batch acted as an unmovable obstacle that adjacent standalone items couldn't drag cleanly past. The only workaround was to ungroup, reorder, and re-group. Now. Batch parents register in the SortableContext under a synthetic
batch-<id>string id, and the parent header carries a realGripVerticaldrag handle next to its existing checkbox + collapse chevron (gated byqueue:reorderlike the per-item handle).handleDragEndlearned to resolve both batch ids: when the dragged source is a batch, the moving block is all of its child items in their current order; when the drop target is a batch, the anchor is the batch's first child so the group lands immediately before it. Direction-aware insertion (dragging downward inserts after the target) uses the first moving id's index instead of the previously hard-coded single dragged id, which keeps multi-row and batch drags converging on the same insert math. Within-batch reorder of expanded children is unchanged — they're still individuallyuseSortable-registered and the per-row drag handle still works. TheDragOverlaygets a second branch for batch drags showing<batch name> (<N> copies)with a Package icon, so the user sees what they're moving. i18n. 2 new keys (queue.batch.dragGroupbutton title +queue.dragGhost.batch/..._pluraloverlay text), real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5235 leaves per locale, no English fallback. The FR plural is a legitimate cognate (French plural of "copie" is also "copies") so it's pinned inFR_COGNATESrather than fake-translated. Build + tests. Full QueuePage vitest 26/26 green;npm run buildclean; ESLint clean; i18n parity green. - Per-filament humidity threshold for auto-drying + alarms (#1605, requested by thenewguy) — The single global
ams_humidity_fairknob (default 60 %) drove both the queue / ambient auto-drying trigger AND the hourly humidity alarm — fine for a homogeneous AMS, brutal for the multi-material print farms the reporter described (one AMS dedicated to PLA fine at ~20 %, another holding Nylon that wants <10 %, ASA somewhere in between). The drying RUN parameters were already per-filament (drying_presetsJSON with PLA / PETG / TPU / ABS / ASA / PA / PC / PVA × n3f / n3s temp + hours), but the TRIGGER was a single int. New settingams_humidity_thresholds— JSON map of filament type to threshold percent, with an explicit"default"key for unknown / unmapped types. Empty / unset → both consumers fall back to the existingams_humidity_fairvalue so the upgrade is silent (no behaviour change until the user opens the editor). Mixed-AMS resolution: most-restrictive wins, matching the conservative-params strategy_get_conservative_drying_paramsalready uses for temp / hours selection. A unit loaded with PLA (threshold 60) + Nylon (threshold 20) drying-triggers + alarms at 20 — same lowest-wins logic. Empty / unloaded tray slots contribute no constraint; an entirely empty AMS falls through to thedefaultkey. Two consumer sites rewired in lockstep via a single newPrintScheduler.resolve_humidity_threshold(trays, thresholds, fallback)static method: (1)print_scheduler.py::_check_auto_drying— the per-AMS humidity comparison that decides whether to start drying, whether to stop drying after the 30-minute minimum, and whether to skip a unit entirely; (2)main.pyAMS sensor / alarm worker — the hourly notifier that fireson_ams_humidity_high/on_ams_ht_humidity_high. Both sites read the sameams_humidity_thresholdssetting through the same resolver so a config change can never make the drying scheduler and the alarm path disagree about whether a given AMS is "too humid." UI. New table in Settings → Workflow → Auto-Drying, immediately below the existing Drying Presets table — same visual idiom (filament-type column on the left, value column on the right, italicised "Default (unknown types)" row at the top so the catchall is visible, eight default rows for PLA / PETG / TPU / ABS / ASA / PA / PC / PVA pre-filled from the user's currentams_humidity_fairso the editor starts in a sensible state). Numeric inputs use a draft-on-edit / commit-on-blur pattern (transienthumidityDrafts: Record<string, string>state per row) so intermediate strings like"","3","7"aren't eaten by the[5, 95]clamp while the user is mid-typing. The clamp fires once ononBlur(andEnterblurs the input). Empty value on blur clears that row's override, letting it fall back to the default row (orams_humidity_fairfor the default row itself) — gives the user a no-mystery way to reset to default. The naive controlled-input pattern was caught mid-PR by Maziggy's typing test: typing30into a field showing60snapped to5between keystrokes becauseparseInt("3") | clamp([5, 95])resolved to5before the user finished typing the second digit. i18n. Four new keys in thesettings.*namespace (humidityThresholds,humidityThresholdsDescription,humidityThresholdCol,humidityThresholdDefault) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5232 leaves per locale, no English fallback. Tests. 12 new backend cases intest_scheduler_auto_drying.py::TestResolveHumidityThreshold+TestGetHumidityThresholds— empty overrides → caller fallback, single known type uses override, mixed load picks lowest, unknown type uses thedefaultkey (not the caller fallback), empty tray slots skipped, all-empty trays usedefault, "PLA Basic" / "pla basic" both normalize to PLA, missingtray_typefield is treated as empty, plus the four DB-loader cases (missing setting → empty, empty value → empty, invalid JSON → empty, valid JSON normalizes lowercase filament keys to uppercase but preserves the literaldefaultkey). One updated integration test intest_settings_ui_preferences.pypins the new field in the public_UI_PREFERENCE_FIELDSallowlist so a future regression that drops it would fail the field-set assertion. 1 new frontend case inSettingsPage.test.tsxexercises the editor render on the Workflow tab. Setting is in the public/ui-preferencesallowlist because it's a non-sensitive integer map needed for badge-color rendering on spool / AMS pages without grantingSETTINGS_READ(same rationale asdrying_presetsandams_humidity_fair). What this does NOT do. Provide per-tray override (the resolver runs at the AMS-unit granularity because both the firmware drying command + the alarm fire per AMS, not per slot); auto-detect the user's loaded filaments and pre-populate (the editor pre-fills fromams_humidity_fairand lets the user opt in to overrides); add "keep AMS heater running during print" (a separate firmware-dependent ask in the same issue — needs feasibility check with the reporter before scoping). Full backendpytest -n 306270/6270 in 73 s; ruff clean;npm run buildclean; ESLint clean; i18n parity green. - Per-printer Maintenance Mode toggle (#1476, requested by IndividualGhost1905 / Ferdi SEVER) — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. What was already there, what was missing. The backend field
Printer.is_active: boolhas shipped since the initial Bambuddy release — toggling it viaPATCH /printers/{id}already disconnects MQTT (printer_manager.disconnect_printeratprinters.py:366), stops the printer from being eligible for queue dispatch (print_scheduler.py:520, 1588,print_queue.py:383), excludes it from model-based filament lookups (printers.py:197), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (metrics.py:105,diagnostic_snapshot.py:126,github_backup.py:333,maintenance.py:457), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported byis_activefrom day one. The missing piece was UI exposure.grep is_activeonPrintersPage.tsxreturned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. Card UI — replacement, not addition. Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same<Status>header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated bystatus?.connectedso they fall away naturally with the MQTT disconnect. Three entry points. (1) Printer card three-dot overflow menu —Enter maintenance mode/Exit maintenance modewith a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal —Maintenance modewith the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse ofis_activebecause the user-facing concept is "is this in maintenance" not "is it active"). Mid-print safety prompt. Entering maintenance mode on a printer inRUNNING/PAUSEstate triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. What this does NOT change. No backend change (is_activewas already wired everywhere); no new permission (uses existingprinters:update); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honouredis_active). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at/maintenance(interval-tracked rod-cleaning / lube / belt tasks via the existingMaintenanceHistoryandPrinterMaintenancemodels) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. i18n. Twelve new keys underprinters.maintenance.*(title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. Tests. 4 new cases inPrintersPage.test.tsx::'maintenance mode (#1476)': amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues aPATCH /printers/{id}withis_active: true; active printers never show the maintenance panel. Existing test fixture (mockPrinters) got an explicitis_active: trueto keep the existing 56 tests green on the new render path. Type:PrinterCreate.is_active?: booleanadded to the TypeScript surface so the field flows cleanly through the existingapi.updatePrinterhelper. Build + checks. Full PrintersPage vitest 60/60 green;npm run buildclean; ESLint clean; i18n parity 5228 × 11 locales green.
Fixed
- Connection diagnostic reported false camera-port warning on A1 / A1 Mini / P1 (#1798, reported and fixed by lesbass / Stefano Maffeis in #1799) — The general Connection Diagnostic (
printer_diagnostic.py:124) probed RTSPS port 322 unconditionally, even for printers that don't speak RTSP. A1, A1 Mini, and P1-family cameras use the chamber-image protocol on port 6000 — Bambuddy's live camera client already routes them there viacamera.py::get_camera_port(model). Result: a saved A1 Mini with a working live webcam still saw "Camera port (RTSPS 322) — Port 322 is unreachable" in the diagnostic, and overall status flipped towarningsfor a healthy printer. Reporter confirmed: TCP probe from the container showed 6000 open, 322 refused; camera-specific diagnostic returnedprotocol=chamber_image, port=6000, overall_status=ok. Fix. The diagnostic now resolves the camera port via the sameget_camera_port()the camera client uses (single source of truth — adding a new model incamera.pypropagates here for free). Saved A1 / A1 Mini / P1 → probe 6000, render "Camera port (Chamber Image 6000)". Saved X1 / X2 / H2 / P2 → unchanged, still probe 322 / RTSPS. Pre-save Add-Printer diagnostic where the model isn't known yet falls back to 322 / RTSPS — same conservative default as before, so the Add-Printer flow doesn't regress for users on RTSP models. The diagnostic check id staysport_rtspsfor backward compatibility with snapshot JSON consumers; the actual port / protocol now travel inparamsand the localized title and warn text interpolate{{protocol}}/{{port}}. Frontend.ConnectionDiagnostic.tsxinterpolates the title (previously static) and merges defensive defaults ({ protocol: 'RTSPS', port: 322, ...check.params }) forport_rtspsso an older backend or cached response still renders sensibly. i18n. Title and warn strings updated in all 11 locales (en / de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) to use{{protocol}}/{{port}}interpolation.npm run check:i18nclean. Tests. 2 new backend cases (A1 Mini → 6000 / Chamber Image with the 322 fallback closed assertingport_rtsps=pass; X1C → still probes 322 / RTSPS asserting the existing behavior is unchanged) and 1 new frontend case asserting the rendered "Camera port (Chamber Image 6000)" + "Port 6000 is unreachable" text._port_probedefaults extended to include port 6000. Existing model-less tests unchanged — they go through theprinter.model is Nonefallback path and still probe 322. - **Print-complete
Changelog truncated — see the full CHANGELOG.md for the complete list.