All notable changes to Bambuddy will be documented in this file.
Support bundle audited for new features — adds OIDC, 2FA, API keys, library/inventory/queue/maintenance totals, slicer-API reachability, GitHub backup status, per-printer Obico flag; also redacts two settings that were leaking and fixes a reachability-check architecture bug — The support-info.json block in support bundles auto-includes the settings table (with sensitive-key redaction), so settings-stored features like LDAP, Obico globals, integrated slicing URLs, Tailscale, and queue-drying already flowed through. What was missing was anything stored in dedicated tables, which had grown substantially without the bundle being updated. Triaging the recent OIDC / 2FA / group bugs (#1292, #1297) and the X1C slicer investigation involved repeatedly asking reporters for information that should have been in the bundle. New blocks added to _collect_support_info in backend/app/api/routes/support.py: auth — OIDC providers (cleartext name, is_enabled, scopes, email_claim, require_email_verified, auto_create_users, auto_link_existing_accounts, has_default_group, has_icon, linked_user_count; client_id/client_secret/issuer_url stay out of the bundle), 2FA counts (users_with_totp, email_otp_codes_pending), API key counts (total / enabled / expired), long-lived token counts (total / active), group counts (system / custom). library — library_files_total, library_files_in_trash, library_folders_total, external_folders_total, external_links_total, makerworld_imports_total. inventory — spools_internal, k_profiles_internal, k_profiles_spoolman. queue — pending_total, manual_start_pending, oldest_pending_age_seconds (catches items stuck because their target printer is offline or filament doesn't match). maintenance — items_total, items_enabled. integrations.github_backup — configs_total, providers_used dict (github/gitea/forgejo/gitlab), schedule_enabled_count, last_failure_count. integrations.slicer_api — enabled, preferred, bambu_studio_url_set, orcaslicer_url_set, plus an actual 2-second HTTP reachability ping (bambu_studio_reachable, orcaslicer_reachable) to differentiate "URL empty" from "URL misconfigured" from "service down". Per-printer obico_enabled flag added to each entry in printers[], parsed from obico_enabled_printers setting via a new _parse_obico_enabled_printers helper that tolerates legacy comma-separated formats. Plus three smaller but important fixes caught while testing the bundle against a real instance: (1) mqtt_broker value was leaking — the keyword-substring redaction filter at support.py:850 had no entry that matched the mqtt_broker setting name, so the broker IP (e.g. 192.168.255.16) was appearing in cleartext. Added broker to sensitive_keys. (2) virtual_printer_tailscale_auth_key was leaking — same reason, no keyword in the filter matched _auth_key. Added auth_key to the keyword set, AND added a value-prefix safety net (tskey-) so any FUTURE Tailscale setting with an unexpected name still auto-redacts when its value starts with the Tailscale auth-key prefix. (3) Slicer-API reachability check was always returning null / false even when the slicer was up — two root causes stacked. First, the old code passed info["settings"] (already redacted) into _collect_slicer_api_info, so when bambu_studio_api_url had been redacted to "[REDACTED]", the httpx call hit that literal string and crashed; when the setting was empty, the URL came through as "" and the function returned None. Second — caught on the next round of testing — even after switching to read directly from Settings.value, the check only looked at the DB row, but the real slicer routes (archives.py:3174-3180, library.py) resolve the URL with a three-level precedence: DB setting → app_settings.bambu_studio_api_url (which reads the BAMBU_STUDIO_API_URL env var) → built-in default http://localhost:3001. Most installations run the sidecar on the default port or via env var, so the DB-only check returned null even when the slicer was up and reachable. The collector now mirrors the route's exact resolution path. The block now also reports bambu_studio_url_set_in_db: bool and bambu_studio_url_source: "db" | "env_or_default" | "unset" so triage can see WHICH layer supplied the URL — separates "user explicitly configured it" from "they're using the default port" without leaking the URL itself. Two regression tests pin both layers: test_reachability_uses_unredacted_url (no "[REDACTED]" ever reaches _check_url_reachable) and test_env_var_fallback_url_pinged_when_db_setting_empty (DB empty + env-var-set URL is actually pinged and reported reachable). All new collectors are wrapped in try/except so a single failure on one block can't blank the rest of the bundle. OIDC provider names are passed in cleartext deliberately — they're login-button labels (PocketID, Authentik, Google, etc.), not secrets, and provider-specific behavior (Azure handles claims differently from Authentik) is exactly the kind of detail that makes SSO bugs triagable in one round-trip instead of three. 13 new unit tests in backend/tests/unit/test_support_helpers.py cover the obico-parser edge cases, slicer-API reachability with mocked httpx (including the "404 = reachable" decision, the un-redacted-URL regression, AND the env-var-fallback regression), auth-info OIDC-cleartext-but-no-secrets contract, the GitHub-backup provider/failure aggregation, and the new mqtt_broker / virtual_printer_tailscale_auth_key / value-prefix-based redactions.
Page headers unified across the app: consistent icon size, placement, and subtitle styling (PR #1272 by @EdwardChamberlain, continuation of #1060 / #1203) — Nine pages (Archives, FileManager, Inventory, Maintenance, MakerWorld, Profiles, Projects, Settings, Stats) now share one header pattern: w-7 h-7 bambu-green icon next to a text-2xl font-bold title with a text-bambu-gray mt-1 subtitle underneath, matching the look that landed earlier on Print Queue and Printers. FileManager and Projects dropped their rounded bg-bambu-green/10 rounded-xl p-2.5 icon tile in favor of the plain icon to match the rest. The sidebar's "Queue" nav item is renamed to "Print Queue" (and its icon switched from Calendar to ListOrdered) to match the page header it leads to. The Stats page title is renamed Dashboard → Statistics to match the sidebar nav label that's been pointing at it (the page never was the printer dashboard — Printers is — and the mismatch confused new users; closes a small but recurring source of "where's the dashboard?" support questions). All renames flow through every locale: en/de/fr/it/ja/pt-BR/zh-CN/zh-TW updated for nav.queue, stats.title, plus a new inventory.subtitle key ("Manage your spools" + translations) used by the inventory header. Bonus on top of the stated scope: inventory.toolbar.{filters, view, actions} were untranslated English strings in fr/it/ja/pt-BR/zh-CN/zh-TW — Edward translated them properly in the same pass. StatsPage.test.tsx updated to assert the new "Statistics" title. Build clean, all 35 page tests still pass, i18n parity holds at 4753 leaves across all 8 locales. Maintenance page subtitle keeps its red / amber / green severity color on the "X items due · Y warnings · all up to date" line — the colors carry actual at-a-glance status information, not just visual weight.
Bambuddy now identifies honestly as itself on every outbound request to Bambu Lab / MakerWorld / Bambu Wiki — proactive alignment with Bambu Lab's 2026-05-12 statement on cloud access, which draws a clear line between modifying AGPL code (allowed) and "impersonating official clients in communication with our cloud infrastructure" (not allowed). Bambuddy was already on the right side of that line on the main authenticated cloud path (User-Agent: Bambuddy/1.0 in bambu_cloud.py:_get_headers), but three secondary call sites were sending browser User-Agents — originally added under the assumption Cloudflare's WAF would block non-browser identification. Tested on 2026-05-12 with curl -H "User-Agent: Bambuddy/1.0" against all three: https://bambulab.com/api/sign-in/tfa returned HTTP 400 with the expected application-level {"code":5,"error":"Login failed"} JSON (no Cloudflare interstitial), https://api.bambulab.com/v1/iot-service/api/slicer/setting returned HTTP 200 with the full 576 KB settings response, https://makerworld.com/api/v1/design-service/* returned the same response shape as a Firefox UA, and https://wiki.bambulab.com/* served identical HTML to a Chrome UA. The browser-impersonation was unnecessary. All four call sites now send Bambuddy/1.0 (+https://github.com/maziggy/bambuddy) consistently — the URL in parens makes the source unambiguous so Bambu can distinguish our traffic from impersonators if they ever audit it. Files: bambu_cloud.py (TOTP/TFA path no longer spoofs Chrome UA + Origin + Referer + Accept-Language headers — Origin/Referer were spoofing bambulab.com origin, which the new comment block specifically calls out as removed), makerworld.py (Firefox UA replaced; the Referer header is kept because MakerWorld's CSRF / origin-check middleware uses it on some endpoints, which is functional, not identity-faking), firmware_check.py (Chrome UA on the public wiki scraper replaced — wiki has no special handling for our UA). Separately: the /v1/iot-service/api/slicer/setting endpoint requires a version query parameter in Bambu Studio's XX.YY.ZZ.WW format (the API returns HTTP 400 "field 'version' is not set" without it, and HTTP 422 "Invalid input parameters" for non-matching formats like bambuddy-1.0), but Bambu's server accepts ANY value within that format — verified the same 576 KB response with version=99.99.99.99. The previous default "02.04.00.70" is an actual Bambu Studio release version (2.4.0.70). The default is now "1.0.0.0" (held in a new _SLICER_API_VERSION module constant in bambu_cloud.py and re-exported into routes/cloud.py so the two route defaults stay in sync), which satisfies the format requirement without claiming to be a specific Bambu Studio build. Unchanged on purpose: version="2.0.0.0" parameters in create_setting / update_setting payloads are the preset's format version (extracted from current.get("version", "2.0.0.0") for updates, line 443) — they describe the preset schema, not the client, and stay as-is. Two regression tests rewritten to lock in the new behavior: test_verify_totp_uses_honest_bambuddy_user_agent (was test_verify_totp_includes_browser_headers — asserts UA starts with Bambuddy/, asserts Mozilla/Chrome/Origin/Referer are not present) and test_sends_honest_bambuddy_user_agent (was test_sends_browser_like_headers — same shape, plus continues to assert the deprecated x-bbl-* Bambu-app identification headers are still gone). All 4598 backend tests pass.
Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory (#1119, reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal spool_assignment table — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer of remaining_weight. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active). store_print_data (spoolman_tracking.py:159) had its disable_weight_sync early-return removed; the three sync_ams_tray callsites (main.py:1450 auto-sync, spoolman.py:318 per-printer manual, spoolman.py:517 sync-all) now hard-code disable_weight_sync=True. The spoolman_disable_weight_sync setting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test in test_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_false proves the early-return is gone.
Deleting an SSO user left orphan OIDC/MFA/camera-token rows on SQLite — blocked re-login and leaked auth state (#1285, PR #1295 by @netscout2001) — On SQLite (default deployment) the delete_user route left orphan rows in user_oidc_links, user_totp, user_otp_codes, and long_lived_tokens because the project intentionally runs with PRAGMA foreign_keys=OFF, so the ON DELETE CASCADE declared on those tables never fired. Reported symptom: an admin deleted an OIDC-provisioned user, the user tried to re-login via SSO, the OIDC callback found the orphan UserOIDCLink pointing at the (now missing) user, failed to resolve it, and redirected to account_inactive instead of triggering auto_create_users. The same root cause was leaking MFA secrets (user_totp), pending email OTP codes (user_otp_codes), and per-user camera-stream tokens (long_lived_tokens — verify() would happily match by lookup_prefix even after the owning user was gone). PostgreSQL deployments were unaffected — cascade was firing there. Fix: mirrors the existing APIKey cleanup pattern in delete_user (introduced in PR #1182). backend/app/api/routes/users.py:delete_user now explicitly deletes UserOIDCLink, UserTOTP, UserOTPCode, and LongLivedToken rows owned by the user; also folds in PrintBatch.created_by_id cleanup (same ondelete=SET NULL SQLite-FK-off root cause, the SET NULL block at users.py:393-407 was missing it). backend/app/core/database.py:run_migrations gains an idempotent startup orphan-cleanup that sweeps the four auth tables (DELETE FROM <table> WHERE user_id NOT IN (SELECT id FROM users)), wrapped in begin_nested(), logged at INFO only when rows actually drop — so installations carrying orphans from before the fix are healed automatically without manual DB intervention. No-op on Postgres (cascade already fired) and idempotent on SQLite (second run finds nothing). backend/app/api/routes/mfa.py:list_oidc_links returns "<deleted>" for provider_name when link.provider is null instead of raising AttributeError — covers the symmetric edge case where a UserOIDCLink could reference an orphaned provider. Tests: 14 new/extended. test_users_auth_cleanup.py (new): 5 tests verify delete_user removes OIDC/TOTP/OTP/long-lived-token rows individually + combined-cleanup atomically. test_oidc_relogin.py (new): full end-to-end test reproducing the #1285 symptom — mocked IdP, first OIDC login, admin delete, second OIDC login proves auto_create_users fires again (and pinned the regression boundary by confirming the test fails without the fix). test_orphan_auth_cleanup_migration.py (new): 7 tests for per-table cleanup across all four auth tables, idempotency, no-op on fresh install, and survival of rows belonging to real users. test_mfa_api.py adds TestListOidcLinksDefensiveProviderNull for the null-check. test_auth_api.py::test_delete_user extended to assert all five auth-table side effects (UserOIDCLink, UserTOTP, UserOTPCode, APIKey, LongLivedToken). All 13 PR-added tests + 194 tests in extended files pass; ruff clean. Reported and patched by @netscout2001.
Slicer bundle import 400/502/503 errors now land in the log so support bundles tell us why (#1312, reported by @hasmar04) — Reporter hit 400 Bad Request from POST /api/v1/slicer/bundles when uploading a Bambu Studio Printer Preset Bundle (.bbscfg); a second contributor had reported the same shape the day before. Same bundle file uploaded fine on Martin's dev machine, which strongly points at sidecar-side differences (image version, write permissions on DATA_PATH/bundles, TrueNAS Docker volume perms, etc.) — but triage was blocked because the sidecar's actual reject reason only made it as far as the FE toast. Bambuddy logged just the uvicorn-access line (POST /api/v1/slicer/bundles HTTP/1.1 400), with no detail in the support bundle. The route at backend/app/api/routes/slicer_presets.py:import_slicer_bundle now emits a logger.warning for each of the three failure shapes: 400 (SlicerInputError) — sidecar's reject string is logged alongside the filename and byte count, so we can see "bundle rejected because manifest.json is missing" in the next support bundle without asking the reporter to copy the toast text. 503 (SlicerApiUnavailableError) — logs the configured sidecar URL plus the exception detail (separates "URL wrong" from "sidecar offline"). 502 (SlicerApiError) — logs filename + byte count + error string, useful when the sidecar's DATA_PATH/bundles write fails (the typical 5xx cause on this path). The 400 case is WARNING rather than INFO deliberately — it's an unexpected end-user-visible failure, not a routine event. Existing test_import_bundle_sidecar_400_passes_through now also asserts the reject reason AND the filename appear in caplog, so the support-bundle-includes-the-diagnostic contract is pinned. Doesn't fix #1312's actual root cause (sidecar-side, still under investigation with reporter) — but the next reporter we get on this code path will produce a bundle that contains the answer.
Restarting Bambuddy mid-print triggered plate-check pause + duplicate archive (#1304, reported by @kleinwareio) — When a P1S print was in progress and the user updated the Bambuddy container (latest → daily in the report, but the same path fires on any restart), Bambuddy paused the live print with an "Object detected on build plate" warning AND re-archived the in-progress file as a duplicate. Root cause: the print-start detector at backend/app/services/bambu_mqtt.py:2780 gated on self._previous_gcode_state != "RUNNING", which is true whether we just saw IDLE→RUNNING (a real print start) OR we just constructed a fresh BambuMQTTClient and _previous_gcode_state is still its initial None (catch-up push from a printer already running). The fresh-client case fired on_print_start, which downstream ran the plate-detection-and-pause flow at main.py AND the FTP-download-and-archive flow — exactly the two symptoms in the bug report. Fix: added self._previous_gcode_state is not None to the is_new_print guard, so the first push from the printer in a new process lifetime never counts as a state transition into RUNNING. _was_running still flips to True via the unconditional "Track RUNNING state" block at bambu_mqtt.py:2795, so print-completion detection keeps working — only the start callback is suppressed. Three existing tests that asserted on the old (buggy) behavior were updated to seed _previous_gcode_state = "IDLE" first, matching the realistic lifecycle of a print actually starting (Bambuddy has been observing IDLE/FINISH before RUNNING); they now exercise the correct path. New regression test test_first_running_push_after_bambuddy_restart_does_not_fire_print_start pins the contract for the reporter's exact scenario — and asserts that _was_running still becomes True so completion still fires when the print ends. The is_file_change branch was unaffected (it already required _previous_gcode_file is not None, so restart-catch-up never reached it anyway).
Create User form rejected weak passwords with an opaque "HTTP 422" toast (#1303, reported by @TrickShotMLG02) — Three independent UX gaps stacked on top of each other. (1) Discoverability: the Create User and Edit User modals showed no hint about the backend's password complexity requirements (min 8 chars + uppercase + lowercase + digit + special character; enforced in backend/app/schemas/auth.py:_validate_password_complexity). Reporter typed an 8-character all-digits password and had no way to know why it failed. (2) Validation mismatch: the frontend's pre-submit check at SettingsPage.tsx was only password.length < 6, accepting passwords the backend would reject — every weak password got bounced after the round-trip instead of getting blocked locally. (3) Error display fragility: when the backend returned a 422 with a Pydantic detail array, the API client's error parser at frontend/src/api/client.ts:107 could fall through to the bare HTTP ${status} fallback if the mapped/filtered detail array ended up empty after stripping the "Value error, " prefix — masking the real reason as just "HTTP 422". Fixes: (1) added a passwordRequirements helper line under both password inputs in Create User / Edit User; (2) extracted checkPasswordComplexity into frontend/src/utils/password.ts, called from handleCreateUser and handleUpdateUser before the API request — it returns the same FIRST failing rule the backend's validator would have flagged (uppercase before lowercase before digit before special, matching _validate_password_complexity's order — fixing one rule shouldn't immediately trip a different message), and the submit button is disabled until all rules pass; (3) the API client now falls back to JSON.stringify(detail) when the mapped array is empty, so a malformed but non-empty 422 detail surfaces SOMETHING informative instead of a bare status code. New translation keys settings.passwordRequirements, settings.toast.passwordNeeds{Uppercase, Lowercase, Digit, Special}, plus the existing passwordTooShort text updated from "6 characters" to "8 characters". English + German fully translated (German reporter's locale); FR/IT/PT-BR translated using straightforward equivalents; JA/ZH-CN/ZH-TW seeded with English for the new complexity messages (existing project flow for new strings). 7 new unit tests in frontend/src/__tests__/utils/password.test.ts pin the validator's contract, including the reporter's exact "12345678" input which now produces a local "Password must contain at least one uppercase letter" toast instead of a 422 round-trip.
External NAS scan hung forever and never committed subdirectories (#1299, reported by @joeferrante) — Linking an external mount with ~1200 subdirectories caused the "Link External Folder" modal to spin until the FE gave up, after which the mount appeared in the sidebar but with no subdirectories, and subsequent scans had no effect either. The reporter's support bundle pinpointed two compounding problems. (1) TypeError: unsupported operand type(s) for /: 'str' and 'str' on every STL — 1,606 instances in the log. generate_stl_thumbnail at stl_thumbnail.py:119 does thumbnails_dir / thumb_filename, which requires a Path, but the external-scan call site at library.py:1256 passed both arguments as str (generate_stl_thumbnail(str(filepath), str(thumb_dir))). Every STL crashed inside the try/except and got logged at WARNING level — visible spam but more importantly wasted work (trimesh.load() and matplotlib setup ran before the failing division). Fix: defensive Path() coerce at the top of generate_stl_thumbnail so the function works regardless of how callers pass args. Regression test test_string_arguments_accepted_without_typeerror pins the contract. (2) Scan ran STL thumbnail generation synchronously inside the HTTP request — even after fix (1), trimesh.load() + matplotlib render is 1–5 seconds per STL; on a NAS with thousands of STLs that's hours of work blocking the modal. Frontend would time out, user would refresh, the HTTP request would be cancelled, db.commit() at library.py:1331 would never run, and no folder/file rows would be committed — which is exactly why "subsequent scans have no effect" (each retry started from scratch and hit the same wall). Fix: scan now defers STL thumbnails to a background task. After db.commit(), the route spawns asyncio.create_task(_backfill_external_stl_thumbnails(folder_ids)) with the full set of folder IDs from folder_cache.values() (covers both pre-existing subfolders AND the ones created during this scan — all_folder_ids is snapshotted before the walk and would have missed the new ones), then returns immediately. The background task opens its own async_session, walks every STL file with thumbnail_path IS NULL in the linked folder tree, generates each thumbnail, and commits per-file so a server restart mid-run only loses the in-flight thumbnail. Survives FE refresh because the task lives in the FastAPI event loop, not the request scope. The reporter's smaller mount (/mnt/NAS_3d_files/3mf_Files, 4 subdirectories) used to work because it completed inside the FE timeout window — with this fix, the 1200-subdir parent mount completes equally fast and thumbnails fill in over the following minutes. Auto-scan after create unchanged: FileManagerPage.tsx:1147-1151 still calls scanExternalFolder immediately after createExternalFolder, which is correct UX — what changed is that the scan response now arrives in seconds instead of timing out.
MakerWorld "Open Cloud settings" link landed on the wrong page (#1300) — On the MakerWorld page, the "Open Cloud settings" hyperlink shown in the sign-in-required banner (when no Bambu Cloud token is stored) pointed at /settings?tab=cloud. The Settings page has no cloud tab (its tabs are general/plugs/notifications/queue/filament/network/apikeys/virtual-printer/spoolbuddy/failure-detection/users/backup), so the URL-param check at SettingsPage.tsx:179 (validTabs.includes(tabParam) ? tabParam : 'general') silently fell back to the General tab. The Bambu Cloud login UI actually lives on the Profiles page (/profiles), which already defaults its sub-tab to cloud — the same destination the existing backup.cloudLoginRequired i18n string ("Sign in under Profiles → Cloud Profiles…") documents. One-line fix in MakerworldPage.tsx:438: to="/settings?tab=cloud" → to="/profiles". The Profiles page's useState<ProfileTab>('cloud') (line 2822) means no query param is needed — landing on /profiles opens the Cloud sub-tab directly.
External-spool prints no longer credit usage to AMS slot 0's Spoolman spool (#1276, reported and diagnosed by @ojimpo — regression of #853) — On a single-filament external-spool print (TPU loaded in vir_slot id=254 on the reporter's H2S + AMS 2 Pro), _resolve_global_tray_id in spoolman_tracking.py was crediting the usage to whatever Spoolman spool happened to be linked to AMS slot 0 — a completely unrelated material in the reporter's case. ~48.94 g of TPU was credited to a PLA spool across 4 prints before they noticed. Root cause: BambuStudio encodes virtual tray IDs (254/255) as -1 in the flat ams_mapping array it sends to the printer (a convention already documented in bambu_mqtt.py:start_print()), but the spoolman tracking helper was treating -1 as "unmapped → use position-based default" and the default mapped slot_id=1 → global_tray_id=0. When slot_to_tray[slot_id-1] == -1 and ams_trays contains an external slot (254 or 255), the helper now returns the external tray ID directly, matching the convention start_print() uses on the other side of the pipeline. Prefers 254 over 255 (consistent with single-nozzle tray_now reporting and the vir_slot id=255→254 remap in bambu_mqtt.py:864). Legacy behavior preserved when ams_trays is empty or contains no external slot (callers that don't pass ams_trays keep the position-based fallback). Two regression tests cover the reporter's exact scenario (ams_trays={0,1,2,3,254}, slot_to_tray=[-1] → 254) plus the H2D-deputy case and the fall-through-when-no-external case. Root cause investigation and patch by @ojimpo.
Virtual-printer queue mode now honors workflow default print options (#1235, reported by @jc21, root cause and patch by @jc21 in #1277) — Prints sent from Bambu Studio (or any slicer) to a VP in print_queue mode arrived in the queue with bed_levelling, flow_cali, vibration_cali, layer_inspect, and timelapse set to the SQLAlchemy column-level defaults, never the user's workflow preferences. The reporter happened to have every workflow default set to the opposite of the column defaults, so prints appeared to have all five options inverted; every queue item required hand-editing before dispatch. The manual POST /print-queue/ endpoint reads these fields off the request body (the frontend pulls them from settings before submitting), but the VP-FTP-receive path at backend/app/services/virtual_printer/manager.py:_add_to_print_queue constructed PrintQueueItem without touching them at all — SQLAlchemy then filled in bed_levelling=True, flow_cali=False, vibration_cali=True, layer_inspect=False, timelapse=False regardless of what was in the DB. Fix reads default_bed_levelling / default_flow_cali / default_vibration_cali / default_layer_inspect / default_timelapse via the existing get_setting() helper (same pattern already used in the function for virtual_printer_archive_name_source) and passes them explicitly to PrintQueueItem. A small _bool_setting() helper maps None → AppSettings schema default, so a fresh install with no workflow page customization behaves identically to before. Regression tests: test_add_to_print_queue_uses_workflow_defaults_from_settings (verifies all five settings flow through with values opposite to the column defaults, matching the reporter's exact scenario) and test_add_to_print_queue_falls_back_to_schema_defaults_when_unset (verifies the no-DB-row path).
Linking a Spoolman spool to an AMS-HT slot no longer fails with a CHECK constraint error (#1274, reported by guillaume.houba) — On H2C / H2D, AMS-HT units report ams_id 128+ (one ams_id per unit, single tray). The spoolman_slot_assignments table's ck_ams_id_range constraint only allowed 0-7 (standard AMS) or 255 (external), so the upsert on POST /spoolman/inventory/slot-assignments blew up with IntegrityError: CHECK constraint failed: ck_ams_id_range and the user had no way to link any spool to an AMS-HT slot. Widened the constraint formula to (ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255 — matches the value range the internal spool_assignment table already accepts and leaves room for up to 64 AMS-HT units (the existing bambu_mqtt/usage-tracker code uses the same 128-based addressing). Updated in the ORM model (models/spoolman_slot_assignment.py) and both the SQLite/Postgres CREATE TABLE DDL in core/database.py. New idempotent migration _migrate_widen_spoolman_slot_ams_id_range: Postgres path runs DROP CONSTRAINT IF EXISTS + ADD CONSTRAINT (no data risk — the new formula is strictly wider than the old); SQLite path detects the stale formula in sqlite_master, table-rebuilds via the standard _v2 rename pattern used elsewhere in this file (_migrate_update_auto_link_constraint at database.py:418), and leaves pre-constraint legacy tables untouched. Tests: test_ams_id_check_admits_ams_ht_range (ORM + DDL formula) and test_assign_accepts_ams_ht_id (end-to-end POST /slot-assignments with ams_id=128).
X2D live camera stream no longer cut by Obico polling / snapshot capture (#1271, reported by @clabeuhtegrite) — The MJPEG fan-out broadcaster from #1089 lets multiple browser viewers share one upstream RTSP socket per printer, but internal callers (Obico AI polling at the user's configured obico_poll_interval, and the manual /camera/snapshot endpoint) still opened their own fresh RTSP connections. X1C / H2D / P2S firmware tolerates brief concurrent camera sockets so the gap was invisible there. X2D firmware 01.01.00.00 (and likely future firmwares) enforces strict single-camera-connection more aggressively: every Obico poll (default every 5 s) kicked the live stream, the broadcaster paid the multi-second RTSP handshake to reconnect, and the user saw the stream cut "all the time." New helper try_get_active_buffered_frame(printer_id) at api/routes/camera.py:74 returns the broadcaster's last buffered frame (always <1 s old while any viewer is connected) and None when no viewer is active. Obico's _capture_frame and the /camera/snapshot endpoint check it first and only fall through to a fresh socket when no stream is running — preserving today's behavior when nobody is watching. plate_detection and layer_timelapse deliberately not converted: plate-detection needs guaranteed-fresh frames post-print (false-positive risk if the user already grabbed the print in the same second), and layer-timelapse is for external cameras only. Regression tests: test_camera_snapshot_reuses_buffered_frame_when_stream_active and two TestCaptureFrameSharesBroadcasterUpstream Obico tests.
Usage tracker: spool swaps in UNUSED slots mid-print no longer charge the old spool (#1269, reported by @maugsburger) — Path 2 of the usage tracker (AMS remain% delta fallback) iterated every AMS tray that had a remain% delta, even slots the print never touched. When a user swapped spools in an unrelated slot during a print, the new spool reports remain=0 (no RFID tag yet) while the snapshot from print-start was 100%, so the fallback charged the originally-assigned spool the full 1000 g. Reporter's case: single-filament print on AMS0-T3 (ams_mapping=[3]), swapped a spool in T1 and another in T2 to refill while the print continued — wound up with Spool 27 consumed 1000.0g (100%) on printer 1 AMS0-T1 and Spool 24 consumed 170.0g (17%) on printer 1 AMS0-T2, neither of which were ever in the print. Fix: the fallback now builds print_used_keys from session.ams_mapping, state.tray_change_log, and session.tray_now_at_start (the three runtime signals telling us which trays were actually part of the print), converts each global tray ID to (ams_id, tray_id) using the standard convention (254/255 → external, ≥128 → AMS-HT, otherwise id // 4, id % 4), and skips fallback for trays whose key is not in that set. When all three signals are empty (legacy edge case: no slicer push, no MQTT tray-change events, no tray_now at start) the legacy "scan every tray" behavior is preserved so we don't regress prints with no metadata. Regression test in test_usage_tracker.py::test_skips_fallback_for_trays_outside_print_mapping reproduces the reporter's exact scenario.
Printer card: smart-plug live wattage now rounded to whole watts (#1266, reported by @Carter3DP) — The printer card's smart-plug status badge rendered plugStatus.energy.power raw, so plugs that report fractional watts (Kauf PLF12 via ESPHome / Home Assistant in the reporter's case, but any MQTT plug pushing a float can hit this) showed values like 14.123456789012 W and overflowed the card width. SmartPlugCard and SwitchbarPopover already wrapped the same field in Math.round(); only the printer-card badge was missing the round. Single-line fix at frontend/src/pages/PrintersPage.tsx:4569.
Build-plate icon on archive cards + uniform printer/model line (#1253, reported by @tonygauderman) — Archive cards now show an OrcaSlicer-style bed icon in the printer/model row indicating which build plate the print was sliced for (Cool / Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI), with the full plate name in the hover tooltip. Closes the gap where users had to remember which plate matched a re-print or open the source 3MF in a slicer just to read the bed setting. Card row also unified: archives with a real Bambuddy-printer association used to render as H2D-1 GCODE … while slicer-only uploads rendered as Sliced for X1C GCODE … — same line, two different shapes. Dropped the Sliced for prefix so both render as a uniform <name-or-model> [bed-icon] GCODE <hash> row, scanning the same regardless of provenance. Backend: new bed_type column on print_archives (idempotent ALTER TABLE migration; SQLite + Postgres safe), populated from curr_bed_type in Metadata/slice_info.config (per-plate metadata, the authoritative source — that's the bed type that actually got sent to the printer for the exported plate) with a fallback to Metadata/project_settings.config's top-level curr_bed_type for older 3MF shapes. Wired through both code paths that produce archive responses: archive_to_response() (the hand-rolled dict converter at archives.py:97 — easy to miss, the schema-only change is silently dropped by Pydantic since the route bypasses from_attributes) and the /rescan endpoint, so old archives can be re-parsed by the user via the existing per-archive Rescan button. Newly-ingested archives get the value automatically. Backfill script: scripts/backfill_archive_bed_type.py (with --dry-run) re-opens every NULL archive's 3MF on disk and populates the column — opt-in for users who want their entire history covered without waiting for natural turnover. Auto-loads .env from project root before importing backend modules (since core/config.py:52 reads DATABASE_URL from os.environ at import time, not from pydantic-settings at Settings() time), prints the resolved DB URL with credentials redacted on every run so operators can confirm they're hitting the intended database (Postgres / SQLite — Bambuddy supports both per #1219's DATABASE_URL pathway), and calls init_db() itself before querying so the migration applies even if the script is run against a database the backend hasn't touched yet. Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ (under /img/ because that path was already statically mounted at main.py:5244; the /bed-icons/ toplevel attempted first hit the SPA catch-all and returned index.html as text/html, which the browser then rendered nothing for). New utils/bedType.ts maps slicer strings (case-insensitive) to icon + human-readable label; covers Bambu Studio and OrcaSlicer's diverging spellings for the same physical plate (e.g. Cool Plate ↔ PC Plate, Cool Plate (SuperTack) ↔ Supertack Plate ↔ Bambu Cool Plate SuperTack). Renders on both card-grid view and list view in ArchivesPage.tsx. Unmapped or NULL bed_type simply omits the icon, so cards stay clean for archives created before this change. Note on icon mapping: bed_pei.png → Textured PEI, bed_pei_cool.png → Smooth PEI is a best-guess from the OrcaSlicer asset names — swap the two paths in bedType.ts if a future user reports the icons reversed for their plate.
Spool labels: new 40×30 mm template, hex colour code, bolder brand line (#809 follow-up, requested by @oliboehm) — Three small enhancements to the spool-label printer rolled into one change. (1) New box_40x30 template — 40×30 mm single label, common DK/Brother roll size. Added to _SINGLE_LABEL_SIZES_MM in backend/app/services/label_renderer.py and to the request body's Literal[...] enum in backend/app/api/routes/labels.py; height is ≥ 20 mm so it routes through the existing roomy layout (swatch + QR + full text column). (2) Colour hex code on every label — new _hex_code_label() helper formats data.rgba as #RRGGBB (alpha-stripped, uppercased to match the inventory UI's colour-picker convention) and returns "" for missing/malformed input so the caller skips drawing instead of throwing. Rendered as a small line under the material/subtype line in the roomy layout, and as a third line above the spool ID in the tight (AMS) layout — useful when several near-identical material/colour spools sit next to each other in the AMS or on a shelf. (3) Brand line bigger + bold — the brand on every label now renders in Helvetica-Bold instead of Helvetica regular, with size bumped 5.5pt → 6.5pt on the tight layout and 7pt → 8pt on the roomy layout, so it's the most legible non-ID field at arm's length. Wiring: SpoolLabelTemplate union in frontend/src/api/client.ts extended with 'box_40x30'; LabelTemplatePickerModal gets a new TEMPLATE_OPTIONS entry for it; inventory.labels.templates.box40x30.{label,hint} keys added across all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native, with the existing per-key fallback in the modal as a safety net). The 5-template grid still wraps to 2 columns on small viewports per #1230's fix; modal regression test was widened from 4 to 5 template buttons. Tests: ALL_TEMPLATES parametrize tuple in test_label_renderer.py extended with box_40x30 so all 7 generic invariants (PDF header, empty-input, multi-colour, missing-fields, malformed-rgba, long strings, sheet pagination) cover the new template; new test_hex_color_code_rendered_when_rgba_set (asserts #F5E6D3 appears in the uncompressed PDF for both 40×30 and 62×29), test_hex_color_code_skipped_when_rgba_invalid (regex pin: no #RRGGBB shape on the label when rgba is malformed, except the spool ID's #42), and test_brand_rendered_in_bold_per_809_followup (asserts Helvetica-Bold font reference is in the PDF — caught a regression if the brand line ever reverts to regular weight). All 33 backend tests + 15 frontend modal tests pass; ruff clean.
Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks (#1234, PR #1246 by @MiguelAngelLV) — Adds a copy button (Copy icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing SpoolFormModal pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except weight_used which is reset to 0 (since the new spool starts full) and the RFID identity fields (tag_uid, tray_uuid, tag_type, data_origin) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls api.createSpool (or api.createSpoolmanInventorySpool in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. Implementation shape: SpoolFormModalProps.mode: 'create' | 'edit' | 'copy' (exported as SpoolFormMode) replaces the previous isEditing = !!spool heuristic — every existing call site in InventoryPage.tsx was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on mode directly. The onCopy callback is optional on SpoolCard, SpoolTableRow, and SpoolTableGroup (matches the existing onPrintLabel? pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. Quick Add interaction: the Quick Add toggle is gated mode === 'create' (was !isEditing), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via bulkCreateMutation. i18n: new inventory.copySpool key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). Tests: 3 new in SpoolFormModal.test.tsx (SpoolFormModal copy mode describe block — title shows "Copy Spool", save calls createSpool not updateSpool, weight_used reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in InventoryPageCopyButton.test.tsx (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing SpoolFormBulk.test.tsx and SpoolFormModal.test.tsx renders that omitted the mode prop were updated with the explicit mode="create" so the tightened Quick Add gate doesn't hide the toggle from them. Both InventoryPageCopyButton.test.tsx and InventoryPageDeepLink.test.tsx gained MSW handlers for the modal's open-time fetches (/api/v1/cloud/status, /api/v1/cloud/local-presets, /api/v1/cloud/builtin-filaments, /api/v1/inventory/color-catalog, /api/v1/inventory/spool-catalog, /api/v1/printers/) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's setLoadingCloudPresets(false) finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head).
.bbscfg Printer Preset Bundle import was broken for every user since launch — sidecar compose file pointed at the wrong branch (#1312, reported by @hasmar04, confirmed by @netscout2001) — slicer-api/docker-compose.yml's build.context pointed at https://github.com/maziggy/orca-slicer-api.git#bambuddy/profile-resolver, but the POST /profiles/bundle endpoint plus the uploadBundle multer middleware were only ever committed to a sibling branch bambuddy/bundle-import (commit a3172c5, 2026-05-06). Every user who ran the documented docker compose up -d got a sidecar without the bundle endpoint — their POST /profiles/bundle fell through to the generic POST /profiles/:category handler, which either rejected with "Name cannot be empty" (no name form field sent) or "Invalid file type. Only JSON files are allowed." (the JSON multer filter rejecting the .bbscfg). Fix: bambuddy/bundle-import fast-forward-merged into bambuddy/profile-resolver in the orca-slicer-api repo and pushed, so the compose file's existing branch ref now points at the right commit. No Bambuddy code change. Existing users rebuild with cd slicer-api/ && docker compose --profile bambu build --no-cache --pull && docker compose --profile bambu up -d — --pull is the key flag because BuildKit caches the git fetch context separately from layer caches, so --no-cache alone silently reuses the old branch checkout. New users on 0.2.5+ are unaffected. Lesson on diagnosis flow: the wrong root cause was reported twice during triage before the actual branch mismatch was caught — first as "build a week ago, before the bundle endpoint existed" (correct claim for the wrong branch), then as "rebuild with --pull" (still hit the same bug because the compose file pointed at the branch that never got the work). The reporter's third round of logs — the multer "Only JSON files are allowed" error string from upload.js:17, which only matches uploadJson not uploadBundle — was the smoking gun that no amount of rebuilding would help because the wired-up branch genuinely lacked the endpoint.curl /health. Backend: new _fetch_slicer_health(url) helper in backend/app/api/routes/support.py does a 2-second GET on <sidecar>/health, parses the JSON, and walks every non-dataPath key under checks looking for a version field — needed because the wrapper labels both bambu-studio-api and orca-slicer-api as checks.orcaslicer regardless of which CLI is actually bundled (cosmetic wrapper bug, not Bambuddy's). _collect_slicer_api_info now calls it instead of the bare reachability ping and adds two new fields per side to the integrations block: bambu_studio_version, orcaslicer_version. Captures "unknown" verbatim when the wrapper's --help regex didn't match (which is itself diagnostic). Behavior preserved on error paths: empty URL returns None, connection failure returns {reachable: False, version: None}, malformed/non-200 returns {reachable: True, version: None} so the reviewer can separate network failure from misconfiguration. Trailing-slash in the configured URL is stripped before appending /health. Tests: 9 new in TestFetchSlicerHealth; existing TestCollectSlicerApiInfo tests updated to patch _fetch_slicer_health and assert the new _version fields. All 62 helper tests pass; ruff clean. Docs: bambuddy-wiki/docs/features/slicer-api.md got four additions. (1) Quick Start gains a warning callout that the Compose file builds from a branch tip and a plain docker compose up -d will keep using the originally-built image. (2) The Updating section now recommends docker compose --profile bambu build --no-cache --pull (both flags) and explains why both matter. (3) New troubleshooting entry for the "Name cannot be empty" / "Only JSON files are allowed" .bbscfg import error. (4) New troubleshooting entry for the orphan-container conflict (container name "/bambu-studio-api" is already in use) that hits users whose existing containers were built from an older compose file with un-prefixed image tags. The pre-existing /health version: "unknown" entry also got a note clarifying that the wrapper mislabels the checks field as orcaslicer for both sidecars — both are cosmetic, not stale-image indicators.LDAP settings: "Advanced" collapsible section header was always rendering in English regardless of UI language (#1297, reported by @Fuechslein) — LDAPSettings.tsx:352 calls t('settings.ldap.advanced') || 'Advanced', but the translation key was never defined in any locale file. The || 'Advanced' fallback kicked in and the header rendered as English in every language. Added settings.ldap.advanced to all 8 locales: Advanced (en), Erweitert (de), Avancé (fr), Avanzate (it), 詳細設定 (ja), Avançado (pt-BR), 高级 (zh-CN), 進階 (zh-TW). No component change needed — the fallback now never triggers because the key resolves properly. i18n parity check holds at 4754 leaves across all locales.
Clear Plate button required granting Settings > Read Settings, leaking the entire Settings UI to non-admin users (#1293, reported by @Tivonfeng) — On the Printers page, the "Clear Plate" button is gated on the global require_plate_clear setting being true. The page reads that value from GET /api/v1/settings, which requires Permission.SETTINGS_READ. A user with printers:clear_plate but no settings:read got a 403 on the settings fetch, the frontend's settings query stayed undefined, requirePlateClear evaluated to false, and the button never rendered. The reporter's workaround — also grant settings:read — works but also adds the Settings nav item to the sidebar and grants visibility of SMTP/LDAP/MQTT credentials and every other setting in the DB, which is exactly the leak they were trying to avoid. Fix: new GET /api/v1/settings/ui-preferences endpoint that returns a curated dict of UI rendering fields without requiring SETTINGS_READ — matches the existing GET /settings/default-sidebar-order precedent (intentionally unauthenticated for the same reason — UI rendering needs values that aren't admin-gated). Exposed fields are explicitly opt-in via a _UI_PREFERENCE_FIELDS tuple in routes/settings.py: require_plate_clear, check_printer_firmware, camera_view_mode, time_format, date_format, drying_presets, ams_humidity_good, ams_humidity_fair, ams_temp_good, ams_temp_fair, bed_cooled_threshold. Anything not on that list — including every sensitive field — is never returned, no matter what's in the DB. PrintersPage now fetches from /settings/ui-preferences via a new api.getUiPreferences() client method; the cache key changed from ['settings'] to ['ui-preferences'] so it doesn't collide with the admin-gated full settings query other admin pages still use. As a side-effect, the page's 4 other settings-driven UI features (drying presets, camera view mode, time format display, firmware-check banner) also stop silently degrading for non-admin users — they all live on the same fetch. Regression tests in backend/tests/integration/test_settings_ui_preferences.py pin: endpoint returns 200 without SETTINGS_READ, response includes require_plate_clear as a bool, field set exactly matches _UI_PREFERENCE_FIELDS (so accidentally adding a sensitive field there fails the test), and a "secret canary" test that seeds 23 sensitive keys with recognizable values and asserts none of them appear in either the response keys or the response body. Frontend types in client.ts tighten camera_view_mode and time_format to the same literal unions as AppSettings so the new endpoint slots into PrinterCard's prop types without casts.
LDAP user logins wiped manually-assigned BamBuddy groups (#1292, reported by @Fuechslein) — When an admin assigned an LDAP-authenticated user a BamBuddy group that wasn't mapped from LDAP (e.g. "Administrators" while the LDAP mapping only covered "Users"), the assignment vanished on the user's next login. The reporter's observation matched the code exactly: assigning a group while the user was logged in held until the next login because user.groups was just mutated in memory; on next login, _sync_ldap_user in backend/app/api/routes/auth.py:1187 rebuilt user.groups from LDAP state alone and blew away the manual assignment. The design intent (LDAP truth must propagate, including revocation) was correct, but the implementation was over-broad — every BamBuddy group got wiped, not just LDAP-mapped ones. Fix: _sync_ldap_user now computes the set of "LDAP-managed" BamBuddy group names = values of ldap_group_mapping ∪ {ldap_default_group}. Groups inside that set are still rebuilt from LDAP truth on each login (so revocation works). Groups outside that set are treated as manual admin assignments and preserved. The partition happens via a list comprehension over user.groups; no schema or DDL change. Edge case explicitly tested: a manual assignment to a group that IS in the LDAP mapping is still overridden by LDAP state — once an assignment is in the user_groups table you can't tell manual-but-mapped from LDAP-derived, so LDAP wins for any group it has authority over. Regression tests in backend/tests/integration/test_ldap_group_sync.py cover: manual group survives login (the reporter's exact scenario), revocation still propagates for LDAP-managed groups, default_group persists across empty-LDAP logins, manual assignment to a managed group is overridden, and the realistic mixed case where a user has multiple manual + multiple LDAP groups at once.
Internal inventory: storage_location field was silently dropped on save and never shown in the table (#1291, reported by @needo37) — The storage_location column existed on the Spool ORM model (backend/app/models/spool.py:57) but was missing from the Pydantic schemas in backend/app/schemas/spool.py (SpoolBase, SpoolUpdate, and by extension SpoolResponse). Pydantic silently strips unknown fields, so PATCH writes to /inventory/spools/{id} reached the update route's model_dump(exclude_unset=True) already missing the field, the setattr loop never touched the DB column, and GET responses left it out — the inventory table always showed "—" in the Storage Location column even when the user had typed and saved a value. Only the internal inventory was affected; Spoolman mode worked because it goes through a separate proxy backend with its own schema. Fix is two added fields in schemas/spool.py: one on SpoolBase (covers SpoolCreate + SpoolResponse via inheritance) and one on SpoolUpdate (standalone). Both constrained to max_length=255 to match the DB column's String(255). No route changes needed — the update handler at inventory.py:961 already uses the generic dump-then-setattr pattern that picks up any new schema field automatically. Note on UX intent: storage_location is the user-defined free-text label ("Drybox #1", "Top shelf"), distinct from location which is the AMS slot assignment ("AMS-A slot 3") — keeping both is the right call. Regression tests in test_spool_schemas_storage_location.py lock in: create/update accept the field, the response surfaces it, explicit-null clears via exclude_unset round-trip, omitted-on-PATCH is left untouched (doesn't accidentally clear), and max_length=255 is enforced (so the API returns a clean 422 instead of a SQLAlchemy column-length error).
Archives page didn't auto-refresh when a slicer sent a print to a Virtual Printer — the new card only appeared after switching tabs (#1282, reported by @kleinwareio) — Real-printer prints broadcast archive_created over the WebSocket from main.py's MQTT print_start handler, and the Archives page listens for that event in frontend/src/hooks/useWebSocket.ts:241 to invalidate its react-query cache. The VP file-receive paths in backend/app/services/virtual_printer/manager.py (_archive_file for immediate mode and _add_to_print_queue for queue mode) created the archive and committed it to the DB but never broadcast the event — so the page stayed stale until the user clicked another tab and back, which triggered a refetch on focus. Fix: factored a small _broadcast_archive_created(archive) helper onto VirtualPrinterInstance that imports ws_manager lazily (matches the file's existing late-import convention for archive/queue imports) and emits the same {id, printer_id, filename, print_name, status} payload shape main.py uses. Called from both VP paths immediately after the archive is logged (_archive_file) and after the queue item is committed (_add_to_print_queue). Broadcast failures are swallowed at debug level so a transient WebSocket issue can't break the file-receive flow. The review mode path (_queue_file) is intentionally untouched — it creates a PendingUpload, not a PrintArchive, and renders on a different page. Tests: test_archive_file_broadcasts_archive_created and test_add_to_print_queue_broadcasts_archive_created patch ws_manager.send_archive_created and assert it's called once with the right payload shape. Affects: every Bambuddy install using a VP in immediate or print_queue mode; review mode and proxy mode are unaffected.
Virtual Printer wedged the slicer at "Downloading...(0%)" when a user clicked Print (instead of Send) against a non-proxy-mode VP, and blocked the next dispatch with "The printer is busy with another print job" (#1280, reported by @kleinwareio) — Bambuddy's VP supports two distinct dispatch flows from the slicer: Send (file upload only — the path queue / immediate / review modes are designed for) and Print (file upload + start-print, intended for proxy mode where there's a real printer behind the VP). The reporter's setup was queue mode but they clicked Print, which is unsupported there. The user-facing symptom was wedging instead of a clean error: the FTP upload completed, the file landed in Bambuddy's queue, but Orca's UI froze at Downloading...(0%) and the next attempt was blocked. Cause: the VP's simulated state machine, in backend/app/services/virtual_printer/manager.py::on_file_received, jumped PREPARE → IDLE directly after the FTP upload completed. The Send flow doesn't watch the post-upload state, so Send users never noticed. The Print flow watches the gcode_state cycle expecting PREPARE → RUNNING → FINISH and only releases its in-flight-job lock when it sees FINISH (or FAILED). Going PREPARE → IDLE looks to the Print-flow slicer like "printer abandoned my job without confirming completion" → UI keeps the prior job pinned → next dispatch is blocked. gcode_file_prepare_percent also stayed at "0" for the whole upload window, which is why Orca's "Downloading X%" progress bar never advanced. Fix: on_file_received now transitions PREPARE → FINISH with prepare_percent="100" and the just-completed filename. The VP's 1-Hz periodic status push (mqtt_server.py:363) broadcasts the new state to every connected slicer within a second, so Orca clears its lock and the next dispatch goes through. The transition is gated to .3mf uploads only — auxiliary uploads (printer-side .gcode blobs etc.) leave the visible state alone. Treats Print and Send identically in non-proxy modes — Print is now silently handled as "file received, treat as completed" instead of wedging the slicer. Send remains a no-op behavior change because Send doesn't watch the post-upload state. Tests: 2 new tests in backend/tests/unit/services/test_virtual_printer.py pin (1) the FINISH transition with the correct filename + prepare_percent="100", and (2) the non-3MF guard. Affects every VP mode that isn't proxy (immediate, print_queue, review) on every slicer using the Print flow (BambuStudio + OrcaSlicer in LAN-mode).
External-spool filament selection silently rolled back: every "Generic PLA" / preset change for the external slot looked applied in the UI but failed on the printer, and the next print threw "no mapping" (#1279, reported by @kleinwareio) — Repro: P1S, no AMS, vt_tray active. User picks any filament for the external slot via Bambuddy. The UI looked normal, but the printer's MQTT response was {"command":"ams_filament_setting", "result":"fail", "reason":"error string"}. The companion extrusion_cali_sel command succeeded, so the K-profile stuck but the filament identity didn't — and the next print therefore had nothing to map to. Cause: backend/app/services/bambu_mqtt.py::ams_set_filament_setting encoded the single-external-spool case as {ams_id: 255, tray_id: 0, slot_id: 0}. The "LOCAL tray_id = 0" comment in the code was a misread of the printer's response shape (the printer echoes tray_id: 0 as the slot-within-virtual-unit, not the slot index used in the request). Verification: captured BambuStudio → X1C ams_filament_setting publish via mosquitto-compatible paho-mqtt subscriber on the same broker, BambuStudio set the external slot to a PLA preset, the published REQ was {ams_id: 255, tray_id: 254, slot_id: 0, tray_info_idx: "P4d64437", tray_color: "F72323FF", tray_type: "PLA", ...} and the printer's REP returned result: "success". The on-wire convention for ams_filament_setting on the external spool is therefore the global tray index (tray_id: 254), not a local slot number (tray_id: 0). Fix: mqtt_tray_id = 254 for the single-external branch in both ams_set_filament_setting and reset_ams_slot (which shares the convention). The dual-external branch (H2D, len(vt_tray) > 1) was not in the captured exchange and is left at mqtt_tray_id = 0 until a Studio → H2D capture confirms the correct value — a regression test pins the current dual-external encoding so any future change to that branch surfaces immediately. Affected printers: every printer whose MQTT push reports vt_tray as a single-element list — i.e. one external slot. That covers all single-nozzle Bambu printers (P1P, P1S, A1, A1 mini, X1C, X1E) plus dual-nozzle models that use a single external feed (X2D). Not affected by this change: H2D / H2C / H2S, which expose two external slots and go through a separate len(vt_tray) > 1 branch. That branch is preserved at its existing mqtt_tray_id = 0 encoding because the captured exchange did not cover it; if the same misencoding turns out to affect dual-external too, a Studio → H2D capture will surface the right values and a follow-up patch will land. Known asymmetry not touched in this PR: the inline ams_filament_setting built by _probe_developer_mode (bambu_mqtt.py:2971-2985) still hardcodes tray_id=0. The probe is robust to this — its detection logic only matches reason: "verify failed" so it correctly identifies dev-mode regardless of whether the command itself succeeds — but the two builders should be unified in a follow-up. Tests: 5 new tests in backend/tests/unit/services/test_bambu_mqtt.py::TestAmsFilamentSettingExternalSpoolEncoding pin the X1C/P1S/A1 single-external fix, reset_ams_slot symmetry, regular AMS slot encoding unchanged, AMS-HT slot encoding unchanged, and the explicitly-unverified dual-external encoding (so any future change to the dual branch surfaces in diff review).
Scan For Timelapse matched the wrong video when an older print's filename happened to land near a later archive's completion (#1278, reported by @1000Delta) — Repro: P2S in LAN-Only mode (no NTP, so printer clock is drifted +8h from UTC), two prints on the same day. Archive 1 correctly attached video_2026-05-08_09-41-29.mp4. Archive 2 (started at 16:39:09 UTC, expected video_2026-05-09_00-42-42.mp4) reused Archive 1's video with a misleading diff: 0:02:19. Cause: scan_timelapse's Strategy 2 matcher in backend/app/api/routes/archives.py had two compounding flaws. (1) It compared the filename timestamp against both archive.started_at and archive.completed_at with a 48 h tolerance — but the filename always represents the print's START time, never its end, so the end-time branch was a semantic mistake whose only effect was creating false positives. For Archive 2, the stale filename 09:41:29 shifted by hypothesis offset -8h → 17:41:29, which happened to fall ~2 minutes before Archive 2's completion → "diff" 2m19s won. (2) The matcher tried seven hypothesised offsets [0, ±1, ±7, ±8], which densely covers a wide span of the day. Even with the end-time branch removed, the wrong video at offset -7 lands at 16:41:29 → 2m20s from Archive 2's start, beating the correct video's 3m33s at offset +8. Fix: extracted Strategy 2 into a pure _match_timelapse_by_timestamp(video_files, archive_start) helper that (a) only compares against print start time (end-time evidence is handled separately by Strategy 3 via file mtime, which actually does reflect when writing finished), and (b) requires the best (video, offset) pair to beat the next-best pair from a different video by at least 15 minutes. When the top two candidates from different videos are too close to call, the helper returns None so the route surfaces the existing available_files list and the frontend's manual-selection dialog kicks in — which is the fallback the reporter explicitly asked for ("at a minimum, we should support that can fall back to letting the user manually select"). Wide offset support is preserved so EU / JST / AEST users (offsets +1, +7, +9, +10, etc.) still get auto-match when there's no ambiguity. Tests: 17 new tests in backend/tests/unit/test_timelapse_match.py pin the bug case (test_issue_1278_archive2_refuses_to_auto_pick_ambiguous, test_issue_1278_archive1_still_matches_unambiguously), the resolution path once the stale video is cleaned up (test_archive2_resolves_when_stale_video_removed), each of the 7 supported offsets via parametrize, and the supporting invariants (no started_at → None, non-timestamp filenames are skipped, same-video different-offset is not ambiguous, well-separated different videos still auto-pick). Known UX gap not in this PR: if the matcher auto-picks a wrong match, the user must delete the attached timelapse first before re-scanning — scan_timelapse short-circuits with status: "exists" when timelapse_path is already set. Adding a force-rescan or "wrong match, pick from candidates" affordance is a separate change.
Docker image: pip upgraded to >=26.1 to close CVE-2026-6357 (medium) — The python:3.13-slim-trixie base image ships pip 26.0.1, which runs its self-update check after installing wheels. A hostile wheel that included a module named like a deferred stdlib import (urllib, ssl, …) could therefore hijack imports inside the just-finished install step. The exploit path is theoretical for Bambuddy itself — we don't install user-supplied wheels at runtime — but the vulnerable pip version still ships inside the image, GitHub code-scanning flagged it (alert #778), and any downstream user who pip installs into the running container inherits the issue. Fix: Dockerfile now runs pip install --upgrade 'pip>=26.1' immediately before pip install -r requirements.txt, so the requirements install itself happens under the patched pip and the resulting pip-*.dist-info/METADATA Trivy reads from the layer is the fixed version. No requirements.txt change — the floor is enforced at the image-build layer where the vulnerable copy lived. (libexpat1 alert #795 also flagged by code-scanning is a DoS-only XML attribute-collision CVE with no patched Debian trixie package yet — left open as a tracking signal; next base-image rebuild after trixie ships libexpat 2.8.1 will close it automatically.)
Gitea backups silently failed after the first run; Forgejo v15 token-scope quirk broke "Test Connection"; many failure paths surfaced cryptic one-word errors (#1224 reported by @rtadams89, #1239 + PR #1255 by @BurntOutHylian) — Two intertwined problem clusters on the Git-backup path, fixed as one PR. (1) Gitea backups quietly stopped after run #1. The Git backup service used GitHub's Git Data API (POST /git/blobs → /trees → /commits → PATCH /refs) for every push. Gitea does not implement these write endpoints on modern versions, so every blob POST returned 404; the loop's continue-on-non-201 pattern left the change list empty and the route returned {"status": "skipped"} instead of committing — no toast, no log row, just "no changes" forever. The first run only worked because the empty-repo path already used the Contents API. Fix: GiteaBackend.push_files is overridden to use POST /repos/{owner}/{repo}/contents with a files array — every changed file is sent as operation: "update" (with its current blob SHA) or operation: "create", the whole batch commits in a single round-trip, no partial-commit failure mode possible. _create_branch_and_push switched from the unimplemented POST /git/refs to POST /branches with {new_branch_name, old_ref_name}. (2) Forgejo v15+ returns 404 (not 403) for private repos when the token lacks repository scope, indistinguishable on the wire from "repo not found / token typo" — Test Connection's existing 404 branch said "Repository not found", which sent users chasing the wrong cause. Fix: new ForgejoBackend (inherits GiteaBackend) overrides test_connection to GET /user first; 401 = bad token, 403 = zero-scope token ("read:user scope missing"), 404 on the subsequent /repos/ call surfaces the v15-specific "private repo with scope mismatch" hint instead of the generic message. Hardening pass on the broader backup stack (B18–B26 review round): every response.json()[...] indexing in github.py (9 sites: ref/commit/blob/tree/commit/ref across push_files + _create_branch_and_push + _create_initial_commit) now routes through a new base.py::_read_sha(response, *path) helper that returns (sha, error_reason) — a malformed body no longer bubbles KeyError('object') through the catch-all to surface as the cryptic one-word string "'object'" in last_backup_message. Tree-fetch failures (GitHub side, mirroring the Gitea side) now return failed with status code + truncated body instead of letting existing_files silently stay empty (which forced every file to re-upload and produced a downstream 422 with no hint at the real cause). GitHub's _create_branch_and_push failure message includes the HTTP status code (an empty-body 422 now produces a diagnostic message instead of "Failed to create branch: "). Both backends detect truncated: true on the tree-listing response (GitHub's tree API truncates at >7MB / >100k entries) and fail loudly asking the operator to rotate the backup repo — previously a truncated listing made the SHA-equality dedup miss and silently re-uploaded every file each run. test_connection failure messages now include str(e)[:200] alongside the exception class name, so the UI surfaces "Connection failed: ConnectError: certificate verify failed: hostname mismatch" instead of just "ConnectError". Gitea's 409-on-/contents message was softened from "stale blob SHAs" (one possible cause) to "the branch likely advanced concurrently (web-UI edit, another backup run, or path-vs-tree collision)". Every status-code branch in github.py and gitea.py mid-push now emits a logger.warning with owner/repo context (previously only the outer except logged, so a 403/404/422 left a DB row with no application-log entry). Recursive push_files re-entry after branch create now logs "Re-entering push_files after branch create owner/repo -> branch" at info level so replication-lag second-pass failures are debuggable. Tests: +17 new unit tests in test_git_providers.py covering the GitHub robustness paths (tree-fetch failure, truncated tree, malformed JSON for ref/commit/blob, 403/422 on _create_branch_and_push), the Gitea round-2 hardening (truncated tree, status code in get_current_commit / extract_tree_SHA / get_repo_info failures, log marker emission), and the Forgejo connection-failure detail. Existing 86 → 103 tests, all pass; full backend suite + integration backup tests green; ruff clean. Tested by @BurntOutHylian against Gitea 1.24.7 / 1.25.4 / 1.26.1 and Forgejo v11 / v15 LTS. Companion wiki update at maziggy/bambuddy-wiki#28.
Printer card's "Show on Printer Card" smart-plug button toggled power without confirmation (#1260, reported by @thkl) — Smart plugs with the "Show on Printer Card" option enabled appear as a clickable chip in the printer card's HA-entities row (below the main Smart Plug controls). One click cut power to the printer instantly — including mid-print — even though the main Off button next to it already routes through a ConfirmModal and shows an additional running-print warning. Fix: the HA-row click handler in frontend/src/pages/PrintersPage.tsx now branches on entity type — script.* entities keep firing instantly (a script is a fire-once trigger, not a power switch, and the existing semantic of "Run" matches user expectation), but switch/light/anything-else entities now open a new ConfirmModal first. The modal reuses the same variant="danger" + running-print warning shape as the existing power-off confirmation: when status?.state === 'RUNNING' it shows the "WARNING: is currently printing! Toggling may cut power and interrupt the print" copy, and renders the default-variant "Toggle the Home Assistant entity ?" message otherwise. The entity name comes from ha_entity_id (with name fallback) so the modal disambiguates which of multiple plugs the click was on. i18n: new printers.confirm.{haToggleTitle, haToggleMessage, haToggleWarning, haToggleButton} keys added across all 8 locales (en + de + fr + it + ja + pt-BR + zh-CN + zh-TW translated to native, no English-fallback seeding). Full PrintersPage frontend suite (49 tests) still passes; build clean.
X2D / H2D dual-nozzle without AMS: filament mapping reported "Required filament type not found in printer" even when the spools were physically loaded (#1257) — Repro: X2D with 0 AMS units, two external spools (Ext-L feeding left extruder, Ext-R feeding right), print job specifies nozzle_id per filament. The Schedule Print modal showed the orange "Filament Mapping (Type not found)" header and a forced manual slot picker, even though the matching PETG was sitting right there in the external spool holder. Cause: frontend/src/hooks/useFilamentMapping.ts:18-19 derived dual-nozzle status solely from printerStatus.ams_extruder_map being non-empty. That map is populated from AMS units' info bits, so a dual-nozzle printer with zero AMS units gets an empty map → hasDualNozzle = false → external spools' extruderId falls through to undefined (line 64 ternary fallback). The downstream nozzle-aware filter at lines 117 / 377 (available.filter((f) => f.extruderId === req.nozzle_id)) then rejected every loaded filament because undefined !== 0/1 for any non-null nozzle_id. The PETG was loaded, just incorrectly stripped from the candidate set during matching. Fix: widen the dual-nozzle inference to three independent signals OR'd together: (1) nozzles[1].nozzle_diameter populated — the most direct signal, set by bambu_mqtt.py:2619-2621 only when the printer reports a right_nozzle_diameter MQTT field, so a populated value always implies real second-nozzle hardware; (2) ams_extruder_map non-empty — preserved as fallback for the dual-nozzle-with-AMS case the original code already handled; (3) vt_tray.length > 1 — single-nozzle printers (P1S / A1 / X1C) only have one external feed, so multiple external trays only exist on dual-nozzle hardware. The first signal alone is not sufficient because the backend state.nozzles defaults to a 2-entry list with empty NozzleInfo() stubs (bambu_mqtt.py:160) on every printer, single-nozzle included — nozzles.length would always be 2 on the wire and would have regressed every single-nozzle install. Affects all dual-nozzle printers running without AMS: X2D, H2D, X2 Pro. Tests: two new regressions in src/__tests__/hooks/useFilamentMapping.test.ts. matches external spools per-extruder on dual-nozzle without AMS pins the bug fix — asserts each external spool gets the correct extruderId (1 for Ext-L id=254, 0 for Ext-R id=255) and computeAmsMapping picks Ext-L for a left-nozzle requirement. does not fabricate extruderId for single-nozzle with stub nozzles[1] is the matching guard — asserts that a P1S / A1 / X1C-shape PrinterStatus (with the default-stub second nozzle entry the backend always emits) does NOT trip the dual-nozzle inference, so single-nozzle external spools keep extruderId=undefined exactly as they did pre-fix. Together they pin both directions: a future change that re-breaks the X2D path fails CI, and one that mistakenly turns single-nozzle printers into dual-nozzle also fails CI. Full frontend suite (1891 tests across 138 files) green.
GCode Viewer had no in-app way to navigate back — the only exit was the browser's back button — Opening the GCode Viewer from a File Manager card or an Archive card calls navigate('/gcode-viewer?archive=…' | '?library_file=…'), which mounts GCodeViewerPage as a full-height iframe inside the Layout shell. The page rendered nothing but the iframe, so once the third-party viewer's UI took over the content area there was no in-app affordance to return to the originating list — only the browser's back button. Reported by @maziggy. Fix: added a thin back bar above the iframe in frontend/src/pages/GCodeViewerPage.tsx with an ArrowLeft icon button. The button label adapts to the entry point — Back to Print Archives when the URL carries ?archive=, Back to File Manager when it carries ?library_file=, generic Back otherwise (covers the rare deep-link / shared-URL case). Click prefers navigate(-1) so the user lands back in their original list with scroll position and filters preserved; falls back to /archives or /files when the page was opened in a fresh tab and there's no SPA history to return to. Iframe height is now flex: 1 inside a flex column under the bar instead of a hard-coded calc(100vh - 3.5rem) — the layout's existing fixed-header offset is unchanged, only the back bar (~36 px) is subtracted from the viewer's vertical real estate. i18n: new gcodeViewer.{back,backToArchives,backToFiles} namespace added to all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native using each locale's existing page-title vocabulary — Druckarchiv/Dateimanager, Archives d'impression/Gestionnaire de fichiers, Archivi di stampa/Gestore file, 印刷アーカイブ/ファイル管理, Arquivos de impressão/Gerenciador de arquivos, 打印归档/文件管理器, 列印歸檔/檔案管理器).
Archives card's "Reprint" / "Schedule" / "Slice" button labels truncated to "Re..." / "Sc..." on narrow browser windows (#1249) — The action row on each archive card has six buttons: two labelled (Reprint + Schedule, or Slice when the file isn't sliced yet) plus four icon-only utilities (open in slicer, external link, globe, download, trash). The labelled buttons used flex-1 to share whatever space remained after the four fixed-width icon buttons, with the label rendered as <span className="hidden sm:inline truncate">...</span> — i.e. visible at any viewport ≥ 640px, with truncate ellipsizing when there isn't room. The Tailwind viewport breakpoint can't see the card width. The page's grid grows column count alongside viewport (md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4), so cards stay roughly 320–380 px wide across breakpoints and the leftover ~30 px in each labelled button isn't enough for "Reprint", which lands on screen as "Re..." — repro'd from a small browser window in the reporter's case. Fix: breakpoint bumped from hidden sm:inline → hidden xl:inline on all three labelled buttons (Reprint at line 1106, Schedule at line 1117, Slice at line 1153 of frontend/src/pages/ArchivesPage.tsx). Labels now appear only at viewport ≥ 1280px where the cards (3-4 columns of ~320 px) actually have headroom for them; on narrow windows the buttons render icon-only with their existing title= tooltip kept intact for hover and assistive-tech disclosure. Trade-off accepted: a wide-viewport-with-wide-sidebar setup that compresses the card to under ~320px will still see the truncation, but that's a corner case — the common "small browser window" path is fixed without restructuring the row.
Spool form's "Slicer Preset" dropdown silently dropped Local Profiles when Bambu Cloud was connected, and collapsed per-printer/per-nozzle variants of cloud and local presets into a single entry (#1248, reported by @andretietz) — Two distinct defects in the same code path. Defect 1 (the reported bug): buildFilamentOptions in frontend/src/components/spool-form/utils.ts was precedence-based — if (cloudPresets.length > 0) returned the cloud list and never reached the local-presets branch, so any Local Profile imported via Profiles → Local Profiles was silently invisible whenever the user was logged into Bambu Cloud (the same profile rendered fine with a green Local badge in the AMS Slot configuration modal). The wiki documents the dropdown as "merged and deduplicated" across cloud + local + built-in. Defect 2 (surfaced during fix verification): the spool form was collapsing all @Bambu Lab P1S 0.4 nozzle / @Bambu Lab X1C 0.4 nozzle / @Bambu Lab A1 0.4 nozzle variants of "Bambu PLA Basic" into a single dropdown entry by stripping the @printer suffix and dedup'ing by base name (one Map.set per family for cloud defaults, one per family for local presets). The AMS Slot modal lists each variant individually and filters by the active printer model, so the user observed strictly more entries in the AMS Slot than in the Add Spool modal even after the merge fix. The right semantic for the spool form — printer-agnostic by design, since a spool isn't bound to a printer — is to show every variant as its own row, exactly as if you'd summed the AMS Slot's per-printer-filtered output across all printers. Fix: rewrote buildFilamentOptions to (a) actually merge all three sources, dropping the precedence early-return, and (b) push each cloud setting_id and each LocalPreset row as its own FilamentOption instead of collapsing by name.replace(/@.*$/, ''). displayName now keeps the full @printer 0.4 nozzle suffix so users can pick the right variant. Built-in dedup against cloud setting_id is preserved (mirrors ConfigureAmsSlotModal.tsx:498 exactly). Wired api.getBuiltinFilaments() into both callers — SpoolFormModal and SpoolBuddyWriteTagPage. Persistence safety: the saved slicer_filament shape is unchanged — cloud picks still persist their setting_id, local picks still persist preset.filament_type || String(preset.id) (consumed by backend/app/utils/filament_ids.py::normalize_slicer_filament which expects GFL05/GFSL05 shapes; persisting the bare LocalPreset row id would break slicing). Local-preset allCodes now carries both the filament_type form and the String(preset.id) form so findPresetOption resolves both old (pre-fix) and new picks. React-key collision: with collapse removed, two LocalPreset rows can share the same code if they share filament_type; the dropdown key in FilamentSection.tsx is now composed ${option.code}::${option.name} to stay unique. Tests: new frontend/src/__tests__/components/spool-form/buildFilamentOptions.test.ts with 9 cases — the #1248 regression case, "one entry per cloud setting_id, no @printer collapse", "list each local preset individually", "@printer suffix preserved in displayName", local allCodes carrying both shapes, the GFA00↔GFSA00 built-in dedup, the all-empty fallback, and the alphabetical sort. The two existing vi.mock('../../api/client') blocks in SpoolFormModal.test.tsx and SpoolFormBulk.test.tsx were updated with the new getBuiltinFilaments stub.
SpoolBuddy install.sh re-run failed with Permission denied on root-owned files in update mode — download_spoolbuddy() ran git fetch + git checkout + git reset --hard before the post-install chown at the end of the function. If a previous install left stray root-owned files in the tree (e.g. static/assets/* written by an earlier sudo run, or a frontend build that wrote as root), the git reset --hard step aborted with EACCES on the unlink/replace step before reaching the chown. The script then exited and the kiosk's underlying ownership problem persisted, so the next attempt would fail the same way. Fix: pre-emptively chown -R spoolbuddy:spoolbuddy "$INSTALL_PATH" in the update branch before any git operation runs. The script already runs as root (enforced by check_root), so the chown is always safe. The existing post-install chown at the end stays — it now mostly catches new files created during this run that need their ownership normalised. Same root cause showed up on the kiosk's runtime SSH update path (Bambuddy → kiosk: git checkout dev && git reset --hard origin/dev running as the spoolbuddy user) but that path can't chown without sudoers expansion — the install.sh fix is the immediate recovery, and re-running the install script restores a clean ownership baseline that the runtime updater can keep healthy thereafter.
SpoolBuddy SSH update aborted with TypeError: startswith first arg must be bytes or a tuple of bytes, not str after the host-key store succeeded — perform_ssh_update calls asyncssh.import_known_hosts(...) to materialise an SSHKnownHosts object for _run_ssh_command's known_hosts= keyword arg. Both call sites (the stored-key path at line 221 and the just-stored TOFU re-parse at line 272) passed f"{ip} {key}\n".encode() — i.e. bytes. asyncssh's parser does line-based string operations (line.startswith('#') with a str literal), so any bytes input crashes inside its loader with TypeError. The two try/except clauses caught only (ValueError, asyncssh.Error), missing TypeError, so the crash bubbled up and aborted the whole update right after the schema fix successfully persisted the host key. Fix: drop the .encode() at both call sites — pass the str directly. Widened both except clauses to (ValueError, TypeError, asyncssh.Error) so any future asyncssh API surprise degrades to the existing fallback (TOFU mode without host-key verification, with a logger.warning) instead of crashing the update. Existing SSH tests all mocked asyncssh.import_known_hosts itself so they never reached the parser — added test_perform_ssh_update_passes_str_not_bytes_to_import_known_hosts to capture both call sites' arguments and assert isinstance(arg, str) so re-introducing .encode() fails CI immediately.
SpoolBuddy SSH update crashed on Postgres with value too long for type character varying(500) when storing the device's RSA host key — spoolbuddy_devices.ssh_host_key was declared as String(500), which is fine for SQLite (ignores VARCHAR length) and for ed25519 host keys (~120 chars), but RSA host keys in OpenSSH format are typically 370 chars (2048-bit) → 544 chars (3072-bit) → ~720 chars (4096-bit). Postgres enforces the limit strictly, so any kiosk reporting an RSA-3072 or larger host key on the first SSH update aborted at the UPDATE spoolbuddy_devices SET ssh_host_key=... flush — the git fetch + pip install + systemctl restart may have run successfully but the persistence of the TOFU host key failed and the device's update_status was never written. Fix: widened ssh_host_key from String(500) → Text on the model, plus an idempotent ALTER TABLE spoolbuddy_devices ALTER COLUMN ssh_host_key TYPE TEXT migration gated on not is_sqlite() (Postgres-only; SQLite is a no-op since it doesn't enforce VARCHAR length). Existing rows are preserved — TYPE TEXT is a metadata-only change on Postgres for VARCHAR(N) → TEXT so it's a fast migration even on populated tables. Originally introduced in the H1 SSH-host-key TOFU security fix; the 500-char floor was a guess based on ed25519 sizes that the RSA case immediately blew past.
SpoolBuddy kiosk Settings → Update button returned "API keys cannot be used for administrative operations" — Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3 (Restart Daemon / Restart Browser / Reboot / Shutdown), missed in that audit. The POST /spoolbuddy/devices/{id}/update route (kiosk's own Settings → Update Daemon button → SSH update on the kiosk device) was gated on Permission.SETTINGS_UPDATE, but SETTINGS_UPDATE is on the API-key deny-list (_APIKEY_DENIED_PERMISSIONS in backend/app/core/auth.py, introduced in PR #1241). Every kiosk-side request to update the daemon — regardless of the API key's scope set (Read / Print Queue / Control / Legacy) — tripped the deny-list and returned a hard 403 with that message. The 0.2.4b3 fix explicitly carved /update out with the reasoning "replaces the daemon binary, different threat surface" — but that reasoning was wrong: restart_daemon already replaces the running daemon process, so daemon-replacement is not a step up in blast radius. The SSH update is also strictly scoped to the single device the operator physically controls (git fetch + pip install + systemctl restart on that one host) — same threat profile as the system commands already running on INVENTORY_UPDATE. Fix: lower /spoolbuddy/devices/{id}/update from Permission.SETTINGS_UPDATE → Permission.INVENTORY_UPDATE, matching the rest of the kiosk-scoped routes (calibration/tare, display, cancel-write, system/command, system/command-result, update-status). The main Bambuddy in-app updater at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one operates on the Bambuddy host and is correctly fenced behind the deny-list. Tests: test_trigger_update_requires_settings_update (which pinned the broken behavior — 403 on inventory-only key) is renamed to test_trigger_update_accepts_inventory_update and now asserts the inventory-only key reaches the device-state check (409 offline) instead of 403, so a future re-tightening of the gate surfaces immediately. Class-level docstring in test_settings_api_key_scrubbing.py updated to reflect the corrected threat-model reasoning.
Printer file download 500'd on non-ASCII filenames; same crash latent in three sibling endpoints (#1245, reported by @1000Delta) — GET /api/v1/printers/{id}/files/download?path=... raised UnicodeEncodeError: 'latin-1' codec can't encode characters in position … for any path whose filename carried non-ASCII characters (Chinese, Japanese, Arabic, accented Latin), reproducible against P2S firmware on macOS but not target-specific. Cause: the route shoved filename straight into Content-Disposition: attachment; filename="{filename}" — Starlette/uvicorn encodes response headers as latin-1, so anything outside U+0000..U+00FF crashed at write-time. Same pattern existed in three sibling endpoints reachable with user-controlled non-ASCII input: GET /archives/{id}/qr (uses archive.print_name from 3MF metadata, often non-ASCII), GET /projects/{id}/export (uses project.name — the existing sanitiser at projects.py:1648 uses c.isalnum() which passes non-ASCII Unicode through, so the crash propagated), and _stream_pdf in labels.py (latent — current callers pass ASCII-only template names, but the same shape would crash if a future caller passed user input). Fix: new helper backend/app/utils/http.py::build_content_disposition(filename, disposition="attachment") returns an RFC 6266-compliant header with both an ASCII-stripped legacy filename="..." fallback and an RFC 5987 filename*=UTF-8''<percent-encoded> parameter — every modern browser (Chrome / Firefox / Safari / Edge) prefers the *= form when present, so the original filename round-trips intact through Save-As; the ASCII fallback covers IE10-era clients. Helper wired in at all four call sites in one PR (per project rule: no deferred follow-ups). Tests: 20 unit tests in test_http_utils.py pinning ASCII-fallback rules across plain ASCII / Chinese / Japanese / Arabic / French diacritics / .gcode.3mf double-extension / quote-injection / backslash-injection / empty-string and ___.zip edge cases, asserting the helper's output round-trips through latin-1 (the crash condition) for every test input. 6 new integration tests in test_printers_api.py::TestPrintersAPI::test_download_printer_file_non_ascii_filename parametrized over the same character classes (the original 龙泡泡石墩子_p2s_ok.gcode.3mf case from #1245 is included) — each asserts the route returns 200 with an unmangled body, the ASCII fallback in the header matches expectations, and unquote(filename*=) round-trips back to the original Unicode filename. Thanks to @1000Delta for the diagnosis and the proof-of-concept patch on printers.py — the broader audit (three sibling endpoints, helper extraction, latin-1 round-trip assertions) was done on top of that.
#-prefix clone trick, dangling inherits on renamed parents, etc.) by letting users upload a BambuStudio "Printer Preset Bundle" (.bbscfg) once per printer and pick from it for every subsequent slice. Service layer (backend/app/services/slicer_api.py): BundleSummary / BundleNotFoundError types, import_bundle / list_bundles / get_bundle / delete_bundle methods, slice_with_bundle which posts /slice with bundle id + per-category preset names instead of the JSON triplet. Routes (/api/v1/slicer/bundles, all gated on Permission.LIBRARY_UPLOAD): POST / GET / GET :id / DELETE :id. All routes proxy via _resolve_slicer_api_url so they follow the user's preferred_slicer setting (bambu_studio vs orcaslicer). Status-code mapping treats sidecar 4xx as 400, BundleNotFoundError as 404, sidecar unreachable as 503, and sidecar 5xx as 502. Preview-slice (backend/app/services/slice_preview.py::get_preview_filaments) picks up optional bundle_id + printer_name + process_name + filament_names params and routes through slice_with_bundle when set; the cache key picks up a bundle-context fingerprint so different bundle picks on the same file occupy distinct entries — gram numbers in the preview now match what the real print will produce instead of being derived from the file's embedded process settings (which can drift from the triplet the actual slice would use). The library.py and archives.py /filament-requirements routes forward the new params. Dispatch (SliceRequest.bundle: SliceBundleSpec): when set, _run_slicer_with_fallback skips resolve_preset_ref and calls slice_with_bundle; the validator skips the preset-required check so bundle-only requests validate. 3MF + bundle CLI 5xx still falls back to the embedded-settings slice path (used_embedded_settings=True surfaces in the response), and sidecar 404 (unknown bundle / preset name) maps to 400. Frontend SliceModal Bundle tier: new "Slicer bundle" picker at the top of the modal, rendered only when at least one bundle is imported (GET /slicer/bundles non-empty). Selecting a bundle replaces cloud / local / standard preset dropdowns with bundle-scoped pickers (process + per-slot filament names from the bundle) — printer is implicit (each .bbscfg has exactly one). "None" leaves the modal on the original preset-triplet path. Submit routes through SliceRequest.bundle so the backend skips PresetRef resolution and asks the sidecar to materialise the JSON triplet from the stored bundle by name. Frontend types: SliceBundleSpec + bundle?: SliceBundleSpec on SliceRequest; getLibraryFileFilamentRequirements / getArchiveFilamentRequirements accept an optional 4th-arg bundle context object. The orca-slicer-api fork's bundle endpoints (shipped on bambuddy/bundle-import) are the server side of this — see the slicer-api sidecar docker-compose for the matching versions.feature/spoolman-inventory-ui testing; fixing them as one batch because they all live on the SpoolBuddy + Spoolman path. (1) /spoolbuddy/nfc/tag-scanned always tried local DB first and only consulted Spoolman as a fallback on local-DB miss, so a stale local copy of a tag silently won over the authoritative Spoolman row, and deleting the local copy was the only way to surface the Spoolman match. Now the route gates on _get_spoolman_client_or_none(db) (which already encodes the spoolman_enabled setting + SSRF guard) and routes to whichever inventory backend Bambuddy is configured for — Spoolman exclusive when enabled, local exclusive otherwise. (2) Dashboard "Assign to AMS" button was a no-op when the freshly-matched spool wasn't yet in the cached getSpoolmanInventorySpools query result (newly created or unarchived in Spoolman after the dashboard loaded). The card rendered via its own displayedSpool ?? sbState.matchedSpool fallback, but the modal's stricter displayedSpool && !justLinkedSpool && displayedTagId guard silently failed to mount. New effectiveModalSpool synthesises an InventorySpool-shaped object from the WebSocket-delivered MatchedSpool (a 9-field subset; slicer_filament* are absent but the modal only uses id to route the assign API call and the mismatch check yields 'none' for profile in either case). (3) AMS-page slot picker hid the assigned spool entirely — when a slot had a SpoolmanSlotAssignment (assigned via the dashboard's Assign-to-AMS flow) but no tag-linked spool, the picker explicitly returned null for the assign/unassign branch and only the "Configure" button remained visible. Now the picker resolves the assignment from spoolmanSlotAssignmentsAll + spoolmanInventorySpoolsCache, renders a "Assigned spool: brand · material - color" info card, and exposes an Unassign button wired to a new unassignSpoolmanSlotMutation (calls DELETE /spoolman/inventory/slot-assignments/<id>, mirroring the local-mode flow). (4) LinkSpoolModal showed "Unknown color" for every Spoolman spool because Spoolman doesn't standardise color_name — most installs only populate color_hex and the filament's name (which often carries the colour like "PLA Basic Red"). _map_spoolman_spool now falls back to the filament's subtype (filament name minus material prefix — typically "Basic Red") when color_name is empty, so spools are visually distinguishable in the picker without changes to the frontend. (5) Writing a tag for spool B didn't clear the same tag binding from spool A, so a single physical NFC UID could map to two Spoolman spools at once and find_spool_by_tag returned whichever came first in the cached list (typically the older one) — exactly the symptom maziggy hit during testing where re-writing a tag still surfaced the previously-assigned spool. nfc_write_result now searches Spoolman for any other spool currently bound to the target UID and clears its extra.tag (best-effort: cleanup failure logs a warning but doesn't block the write itself, since the device already wrote the chip). (6) The kiosk display held stale spoolmanSlotAssignments cache because the SpoolBuddy display is a long-running browser window with no focus/remount triggers, so a staleTime alone never caused a refetch. State changed elsewhere (Bambuddy main UI, direct Spoolman edit) was invisible to the kiosk and isSpoolAssigned reported assigned-forever — the Assign button stayed disabled, the Unassign button stayed enabled, after the spools were already unassigned. Adds refetchInterval: 3_000 (cheap query, bounded latency below operator-noticeable) so the kiosk picks up external changes within seconds. (7) Kiosk QuickMenu System buttons (Restart Daemon / Restart Browser / Reboot / Shutdown) all 403'd silently — the /spoolbuddy/devices/<id>/system/command route was gated on Permission.SETTINGS_UPDATE (T-Gap 2 from a prior security audit), but every other kiosk-scoped device route (calibration/tare, display, cancel-write, system/command-result) uses INVENTORY_UPDATE. The kiosk's operator session has INVENTORY_UPDATE but not SETTINGS_UPDATE, so every System button silently failed via the modal's catch-block (no toast). Aligned the permission with the rest of the kiosk-scoped routes so operators can recover the kiosk from the kiosk itself. Risk is bounded — only the 4 named commands are accepted (no RCE), reboot/shutdown require physical-access recovery, the same operator already controls printers + weighs spools on the same device. The /update route keeps SETTINGS_UPDATE because that one can replace the daemon binary, which is a different threat surface. Test contract test_system_command_requires_settings_update is renamed to test_system_command_accepts_inventory_update and asserts the inventory-only key now reaches the device-state check (409 offline) instead of 403, so a future re-tightening of the gate surfaces immediately. Tests: new TestMapSpoolmanSpool::test_color_name_uses_explicit_field_when_present / _falls_back_to_subtype_when_field_missing / _none_when_both_fields_empty (3 unit tests pinning the colour-name fallback chain), new TestNfcEndpoints::test_tag_scanned_spoolman_mode_skips_local_lookup (verifies get_spool_by_tag is never called when Spoolman is enabled, even when the lookup would have returned a spool), and new test_write_result_clears_duplicate_tag_binding (asserts merge_spool_extra is called twice — once to clear the old holder's extra.tag, once to bind the new owner — in that order with the right spool ids). Existing 76 helper tests + 7 NFC-endpoint tests still pass.feature/spoolman-inventory-ui testing (extends the #1228 family). After clicking "Reset slot" on an AMS slot that had filament physically loaded, picking an inventory spool from the printer card and clicking Assign showed a success toast — but the slot kept reporting as unconfigured, no ams_filament_setting MQTT command ever fired, and the spool's brand/color never appeared on either the Bambuddy printer card or BambuStudio. Cause: assign_spool in backend/app/api/routes/inventory.py decided the slot was empty using slot_is_empty = not (fingerprint_type and fingerprint_type.strip()) where fingerprint_type came from tray.tray_type. The "Reset slot" command clears tray_type / tray_color / tray_info_idx to empty strings on the printer side but leaves the filament physically loaded. The empty tray_type then misled the heuristic into the pending-config (SpoolBuddy weigh-then-assign) branch, which intentionally skips the MQTT publish because Bambu firmware drops ams_filament_setting on truly unloaded slots. The deferred replay in on_ams_change only fires on an empty→loaded transition — but the slot was already loaded, so no transition ever came and the assignment sat in pending state forever. Fix: capture tray.state alongside the fingerprint fields when looking up the AMS tray (Bambu firmware reports state == 11 for loaded, 9 for empty, 10 for spool present but filament not in feeder; documented at bambu_mqtt.py:1631-1633). When state is reported, slot_is_empty = (state != 11). When state is not reported (older firmware), fall back to the existing tray_type heuristic so legacy installs continue to behave the same. Same logic applied to the external-slot path (ams_id == 255 / vt_tray). Tests: 5 new in TestAssignSpoolEmptyDetection — post-reset (state=11, tray_type="" → MQTT must fire, pending_config=False), genuinely empty (state=9 → MQTT skipped, pending_config=True), legacy fallback both directions (no state field → tray_type heuristic), and the external-slot post-reset variant.pushall + get_version responses, then never opens an FTP connection — the slicer reads the cached push, fails its pre-flight, and aborts before attempting any data transfer. Cause: the 0.2.3.2 synthetic stub baked three SD/storage indicators that BambuStudio's "Send" pre-flight reads — home_flag with bit 8 (HAS_SDCARD_NORMAL, 0x100), sdcard: True, and a storage: {free, total} block. The 0.2.4b3 cached-as-base slicer-mirror (commit 7dea33d0) passes the live target's push_status through with only an IP rewrite; if the real firmware doesn't report those fields (P1S/A1 with no SD card inserted, older field shapes, P1S firmware 01.10.00.00 confirmed in @rtadams89's logs), the slicer sees "no storage" and refuses to send. H2D and X1C in maziggy's local cross-subnet repro worked because those firmwares do report the indicators; P1S/A1-class doesn't always. Fix: in mqtt_server.py:_send_status_report cached-as-base path, after copying the cache, OR 0x100 onto home_flag (preserves any other bits the printer set), force sdcard=True, and setdefault a storage: {free: 1_000_000_000, total: 32_000_000_000} block (only fills in if the real printer didn't report one — real values pass through unchanged when present). For VP usage the slicer uploads via FTPS to Bambuddy's filesystem under /app/data/virtual_printer/uploads/<vpid>/; the printer's actual SD card is irrelevant on that path, so forcing "storage available" is correct for the queue/immediate/review modes the cached-as-base path covers. Restores 0.2.3.2's working behaviour for these specific fields without losing the live AMS / k-profile / camera mirror that cached-as-base provides. Tests: new test_storage_indicators_overlaid_for_send_preflight (verifies SD bit OR'd onto a partial home_flag, sdcard=True forced even when real says False, storage injected when cache lacks it, free/total are non-zero) and test_storage_indicators_preserve_real_storage_when_present (real home_flag=0x100 stays 0x100, real storage={free, total} passes through unchanged so the overlay never overrides what the printer actually reported) in test_vp_mqtt_bridge.py::TestStatusReportCachedAsBase. Existing 25 tests in that suite still pass.MFA_ENCRYPTION_KEY unset, which silently fell back to plaintext storage for OIDC client_secret and TOTP secret rows. The single startup logger.warning was the only signal, and .env.example / docker-compose.yml / Settings UI never mentioned the variable, so any operator who wired up SSO or asked users to enroll in 2FA had to read the warning in the logs to know their secrets were unprotected at rest. Auto-bootstrap: backend/app/core/encryption.py now resolves the encryption key with the same precedence pattern as _get_jwt_secret — MFA_ENCRYPTION_KEY env var → DATA_DIR/.mfa_encryption_key file → auto-generated Fernet key written with mode 0o600. The new helper backend/app/core/paths.py:resolve_data_dir() is shared with auth.py (DRY) and reads the env fresh on every call so test fixtures can override DATA_DIR per-test. Invalid env-var values (anything that doesn't decode to exactly 32 bytes via URL-safe base64) are rejected with a logger.error and the loader falls through to the file/auto-generate branches instead of crashing the encrypt/decrypt path with ValueError. Re-encryption migration: _migrate_encrypt_legacy_secrets() runs once on every startup after run_migrations(conn) finishes — it opens its own async_session() (separate from the schema-DDL connection, to avoid SQLite WAL lock contention) and converts any oidc_providers.client_secret / user_totp.secret row whose value doesn't already start with fernet: to the encrypted form via the existing property setters. The migration is idempotent (prefix check) and is a no-op when no key is loaded, so it can run safely on installs that never opt in. Status endpoint + UI: new GET /api/v1/auth/encryption-status (admin-only, gated on Permission.SETTINGS_READ) returns key_configured, key_source ∈ {env, file, generated, none}, plus per-table legacy_plaintext_rows and encrypted_rows counts and a derived decryption_broken flag (true iff encrypted rows exist but no key is loadable — the Phase-2 "operator deleted the key after rows were encrypted" recovery scenario). The new frontend/src/components/SecurityStatusCard.tsx lives in a new "Security" sub-tab under Settings → Authentication and renders four severity levels: green when everything is encrypted and a key is loaded, yellow when legacy plaintext rows still need re-encryption, orange when the key was auto-generated (with a backup hint pointing at DATA_DIR/.mfa_encryption_key), and red when decryption_broken is true. Backup integration: routes/settings.py:create_backup_zip now includes .mfa_encryption_key as a ZIP top-level entry (alongside bambuddy.db) so a self-contained backup can be restored to a fresh host without losing access to encrypted secrets. The matching routes/settings.py:restore_backup extracts the file back into DATA_DIR with chmod(0o600) and validates the basename exactly (/, .., \\ rejected) so a manipulated ZIP cannot path-traverse outside DATA_DIR. If the file is absent from the ZIP (legacy backup) the restore proceeds without error — the next boot will auto-bootstrap a fresh key, and any plaintext rows that come back from the backup remain readable via the existing legacy-plaintext fallback in mfa_decrypt. Test isolation: new autouse mfa_encryption_isolation fixture in conftest.py per-test points DATA_DIR at a tmp_path, clears MFA_ENCRYPTION_KEY from env, and resets the _fernet_instance / _warn_shown / _key_source module globals — so the auto-bootstrap can never write a real key file into the repo and pytest-xdist workers don't share encryption state. i18n: new settings.encryption.* namespace and settings.tabs.security label across all 8 locales (en + de fully translated; fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English copy pending native translation, matching the project's existing flow for newly-added keys). Docs: .env.example documents the new variable + the backup self-containment behaviour; docker-compose.yml carries an auto-commented entry; .gitignore adds .mfa_encryption_key alongside the existing .jwt_secret project-root guard. Tests: 9 new unit tests in TestEncryption (env/file/generated key sources, invalid-env fall-through, OSError → none, mode 0o600 check), 6 new in TestEncryptLegacyMigration (plaintext → encrypted for OIDC + TOTP, idempotent re-run, mixed state, no-op without key, log assertion), 8 new in TestEncryptionStatusEndpoint (each key_source, count assertions, decryption_broken recovery scenario, Permission.SETTINGS_READ gate), 2 new in TestEncryptionRoundtrip (raw column reads return ciphertext, property reads return plaintext for both OIDC and TOTP), 6 new in TestBackupKeyFiles (ZIP includes / skips key files, restore chmod 0o600, missing-file tolerance, path-traversal rejection), and 6 new frontend tests in SecurityStatusCard.test.tsx (each severity level + the disabled state).d6a31393, "fix(frontend): emit relative asset paths so SPA loads under any subpath") set base: '' in vite.config.ts to support path-prefixed reverse proxies (HA Ingress, nginx subpath, Cloudflare Tunnel path routing). With that, the built index.html references its bundle and stylesheet via relative URLs (./assets/index-XXX.js, ./sw-register.js). When the popup opened at /camera/<id>, the browser resolved ./assets/index-XXX.js against the current document URL — which doesn't end in a slash, so the URL parser treated <id> as a file and /camera/ as the directory, giving /camera/assets/index-XXX.js. The backend's SPA catch-all returned index.html (text/html) for that request, and modern browsers refuse to execute HTML as a JS module under X-Content-Type-Options: nosniff, so the popup loaded the document but never the bundle. Same break hit any deep route on initial load — direct URL paste / refresh on /camera/:printerId, /projects/:id, /groups/:id/edit, /files/trash, /external/:id, and the SpoolBuddy kiosk's /spoolbuddy/ams if loaded directly — manifesting as a quiet "blank page on refresh" that users worked around by navigating from the home page. The console error gives it away: Loading module … was blocked because of a disallowed MIME type ("text/html"). Fix: revert PR #1195's vite.config.ts and sw-register.js changes — base: '' is removed (Vite default '/' restored), and navigator.serviceWorker.register('sw.js') reverts to register('/sw.js'). The built index.html now emits absolute asset URLs (/assets/..., /manifest.json, /sw-register.js) which resolve against host root regardless of document URL, so deep routes load their assets correctly on initial navigation. PR #1195's class of bug — path-prefixed reverse proxy users serving Bambuddy at a subpath — was already explicitly closed as wontfix in that thread because supporting it requires subpath-aware bootstrapping (API_BASE, React Router basename, PWA manifest scope, service-worker scope, push-subscription scope) for every user forever. The supported workaround for that audience is documented: NPM (Nginx Proxy Manager) addon + Cloudflare Tunnel at a real domain with HTTPS, then HA Webpage panel embedding via TRUSTED_FRAME_ORIGINS — that path doesn't depend on base: '' at all. The trade-off here is intentional: revert reaches every user impacted by deep-route initial-load bugs (much larger population than path-prefixed proxy users), in exchange for an already-wontfixed subpath proxy regression that has a working alternative. (#1237, reported by @basziee) — In the Configure AMS Slot modal, profile names like SUNLU PETG GLOW IN THE DARK GEN2 @Bambu Lab H2C 0.4 nozzle were visually truncated mid-name, hiding the @<printer> <nozzle> suffix. With several near-identical entries differing only in nozzle size, users had to open browser dev tools to tell them apart. Fix: the preset row now expands inline on hover — truncate stays as the default (so the list keeps its compact one-line shape) but group-hover:whitespace-normal group-hover:break-all flips it to a wrapped multi-line view the moment the cursor enters the row, so the nozzle suffix is readable instantly without waiting on the browser's title-tooltip delay. The parent button gets group to drive the hover. The native title={preset.name} is also added as a belt-and-braces fallback for assistive tech and touch devices where :hover doesn't fire. Same pattern in both the desktop and mobile layouts of ConfigureAmsSlotModal.tsx. No new dependencies. Test: new ConfigureAmsSlotModal.test.tsx regression assertion that the rendered preset span carries title=<full name> plus the truncate and group-hover:whitespace-normal classes, and the parent button has group — so a future refactor that drops any of those fails CI.bambu_mqtt.py gated on state in ("RUNNING", "PAUSE") literal strings, and P2S firmware briefly transitions out of RUNNING during the AMS swap, so the switch was never appended to tray_change_log; (2) the usage-tracker splitting branch in usage_tracker.py was gated on not slot_to_tray, so even when the tray-change log was populated the splitting code only ran for prints where the slicer's mapping had not been captured — i.e. never on the actual fallback case. Fix: the bambu_mqtt.py gate now keys on the print-lifecycle flags (_was_running and not _completion_triggered) so any tray change between print start and completion is captured regardless of the momentary gcode_state string. The usage_tracker.py gate is split so tray_change_log evidence with > 1 entries always takes over from slot_to_tray, treating the per-segment per-layer gcode usage as the source of truth when the printer actually fed from multiple trays. Path 2 (AMS remain%-delta fallback) then naturally skips both trays because they're already in handled_trays after splitting, eliminating the double-credit. Tests: new test_tray_change_recorded_during_intermediate_state and test_tray_change_not_recorded_after_completion in test_bambu_mqtt.py exercising the new gate; new test_tray_switch_overrides_print_cmd_mapping in test_usage_tracker.py pinning that with ams_mapping=[0] set and tray_change_log=[(0,0),(1,30)] the splitter produces two segments summing to the 3MF estimate (no double-count) and adds both (0,0) and (0,1) to handled_trays.{"detail":"Not Found"} in Docker installs (#1218) — The embedded GCode viewer's static assets (gcode_viewer/) were not copied into the production Docker image, so clicking "3D Preview" on any archive loaded an iframe at /gcode-viewer/?archive=<id> that returned a bare FastAPI 404 — Firefox / Chrome rendered the JSON response inside the iframe area while the outer Bambuddy layout looked normal, masking the failure unless the user actually inspected the iframe. The Vite production build doesn't stage gcode_viewer/ into static/ either (the dev server serves it via a configureServer middleware that's dev-only), and the only integration test for the route accepted 404 as a valid outcome ("assert response.status_code in (200, 404)") so CI never caught the missing files. Affected every Docker build since the embedded viewer landed in 0.2.4b1 (commit 3adce435, 2026-04-22). Fix: Dockerfile now copies the gcode_viewer/ directory alongside the React build output. Defence in depth: backend/app/main.py logs an ERROR at startup when _gcode_viewer_dir / "index.html" is missing so future packaging gaps surface in docker logs and the support bundle instead of as silent runtime 404s. Test guard: backend/tests/integration/test_gcode_viewer.py adds test_gcode_viewer_index_served_when_assets_present which skips when the directory is intentionally absent (unit-test environments) but asserts 200 OK + a non-empty HTML body when the assets do exist on disk — so a future broken COPY fails CI loudly rather than continuing to ship a broken image.filamentReqsQuery.isSuccess to the isReady chain so the button stays disabled while the preview slice is in flight (or before the backend's /filament-requirements call settles for sliced files) and flips to enabled the moment the real slot list lands and auto-pick fills it.create_spool_from_tray filtered by manufacturer + hex only, with no ORDER BY. Three Bambu Lab catalog rows share #FFFFFF — Jade White (PLA Basic), Ivory White (PLA Matte), White (PLA Silk) — and SQLite returned them in rowid order, so the first-inserted entry (Jade White) won every time regardless of the actual material the AMS reported. Same class of bug bites any other shared-hex pair across PLA Basic / Matte / Silk; the whites were just the most visible. Fix: spool_tag_matcher.py::create_spool_from_tray now filters the catalog by tray_sub_brands too — the printer-reported material variant ("PLA Matte" / "PLA Basic" / "PLA Silk") matches the catalog's material column directly. The query also gets an explicit ORDER BY id so the fallback path (when tray_sub_brands is empty — third-party spools / OpenTag tags) is deterministic across SQLite + PostgreSQL instead of DB-implementation-defined. The catalog lookup uses the raw tray_sub_brands value (before the gradient/dual/tri-color subtype upgrade at lines 73-87) because the catalog stores "PLA Basic" for gradient rolls too — the upgraded subtype lives on the spool, not the catalog row. Note for affected users: spools already in the database under the wrong colour name (e.g. four Ivory White rolls labelled "Jade White") don't auto-correct on next AMS read — the matcher only fires when creating a new spool from RFID. Existing rows need a manual rename in Inventory after upgrading. Tests: 4 new in test_spool_tag_matcher.py — test_ivory_white_pla_matte_resolves_to_ivory_not_jade (the #1227 regression pin), test_pla_silk_white_resolves_to_white_not_jade (the third collision), test_jade_white_pla_basic_still_resolves_correctly (happy-path guard with all three #FFFFFF entries seeded), and test_unknown_material_falls_back_to_hex_only_lookup (third-party / empty tray_sub_brands path stays deterministic via ORDER BY).GitHubBackend because PR #1160's class docstring assumed Gitea's Git Data API was fully GitHub-compatible. (1) List-shaped ref response: GET /api/v1/repos/{owner}/{repo}/git/refs/heads/{branch} returns a list of matching refs on Gitea/Forgejo even when only one matches ([{"ref": ..., "object": {"sha": ...}}]), whereas GitHub returns a single object. The inherited push_files and _create_branch_and_push did ref_response.json()["object"]["sha"] and crashed with list indices must be integers or slices, not str — surfacing as the failure at the top of any push against a populated Gitea repo (#1225's symptom, and #1224's symptom once the user committed any file before the first backup). (2) Empty-repo writes refused: GitHub's Git Data API accepts POST /git/blobs against a brand-new empty repo and creates the initial commit + branch implicitly. Gitea refuses every blob/tree/commit POST with 404 until the underlying git repo has at least one commit — so the inherited _create_initial_commit (which posts blobs → tree → commit → ref in that order) silently failed: every blob POST returned 404, tree_items ended up empty, and the next tree POST also returned 404 ("Failed to create tree" — #1224's symptom on a freshly-created empty Gitea repo). Fix: GiteaBackend now overrides push_files, _create_branch_and_push, and _create_initial_commit directly instead of inheriting them. The Git Data API path uses a _ref_sha() helper that accepts both list and dict shapes; the empty-repo bootstrap route uses Gitea's Contents API (POST /api/v1/repos/{owner}/{repo}/contents with a files array, branch=<target>, new_branch=<target>) which seeds the initial commit + branch in a single transaction — Contents API is documented to work on empty repos because it goes through Gitea's higher-level repo-init path. GitHubBackend is untouched — the GitHub backup path is proven working, the fix is fully isolated to the Gitea side. ForgejoBackend(GiteaBackend) inherits both fixes automatically; tests pin that. Tests: 10 new tests in test_git_providers.py — TestGiteaBackendListShapeRefResponse (4 tests: _ref_sha accepts list/dict/empty-list, plus full push_files happy paths against list-shaped branch ref and list-shaped default-branch ref), TestGiteaBackendEmptyRepoInitialCommit (4 tests: empty repo routes through Contents API exclusively with no blob/tree/commit/ref Git Data API calls, payload shape verified field-by-field against Gitea's documented schema, error truncation works, empty file dict returns skipped without firing a useless API call), and TestForgejoInheritsGiteaFixes (2 tests: list-shape and empty-repo paths both work via inheritance). Existing 6 TestGiteaBackendPushFiles tests still pass since _ref_sha accepts dict-shaped responses too. Total: 78 tests pass across the backup unit + integration suites; ruff clean. Follow-up fix (still under #1224): subsequent backups against Gitea 1.24+ then failed with the opaque "Backup failed: 'tree'" because Gitea's GET /repos/{owner}/{repo}/git/commits/{sha} returns the wrapped Commit schema (tree at commit.tree.sha), whereas GitHub's same-named Git Database endpoint returns the unwrapped GitCommit schema (tree at top level). The bare commit_response.json()["tree"]["sha"] lookup at gitea.py:109 raised KeyError: 'tree' and the broad except surfaced it as the opaque message. Fix: _commit_tree_sha() helper that tries the flat shape first (GitHub-compatible / older Gitea) and falls back to the wrapped shape (Gitea 1.24+, Forgejo) — keeps the existing-files diff working on both shapes so subsequent backups don't re-upload every blob. Tests: new TestGiteaBackendWrappedCommitResponse (4 tests: helper accepts flat / wrapped / missing shapes, full push_files succeeds against a wrapped commit response, failure path surfaces a clear error message instead of KeyError when the tree SHA can't be extracted).root:root, and the previous chmod 777 /app/data Dockerfile workaround only covered the named-volume root — so subdirs Bambuddy creates at runtime (virtual_printer/uploads, virtual_printer/certs, etc.) inherited wrong ownership when the container ran as 1000:1000. (2) The shipped docker-compose.yml ships ./virtual_printer:/app/data/virtual_printer uncommented, and dockerd creates a missing bind-mount source on the host as root before the container starts — leaving the host directory unwritable by uid 1000 inside the container even though the named volume above it had the chmod-777 workaround. Symptom either way: [Errno 13] Permission denied: '/app/data/virtual_printer/uploads', no virtual printer ever starts, "VP doesn't work" support reports follow. Replaces the chmod-777 hack with a proper entrypoint: deploy/docker-entrypoint.sh runs as root, chowns /app/data and /app/logs (and /app/data/virtual_printer when bind-mounted) to PUID:PGID, then drops to that uid via gosu before exec'ing the app. The chown is gated behind a top-level ownership check so subsequent restarts skip the recursive traversal — no multi-second startup penalty on multi-GB archive directories. A sentinel .bambuddy file in each data path prevents Docker from re-syncing image directory metadata on every mount (otherwise empty volumes have their ownership reverted from the image on each restart, defeating the idempotency). When the container is started with an explicit user: directive or --user flag the entrypoint detects it isn't root and falls through to direct exec — preserving compatibility for users who pin a specific uid. Compose template changes: removes user: "${PUID:-1000}:${PGID:-1000}" (the entrypoint owns privilege drop now), adds PUID / PGID env vars with the same defaults, and comments out the ./virtual_printer:/app/data/virtual_printer bind mount by default with explicit "only needed if you also run a native install of Bambuddy on the same host and want both to share the VP CA cert" guidance. The entrypoint chowns the host-side dir through the bind mount the first time it sees wrong ownership, so existing uncommented installs continue to work and #1211 specifically gets fixed.max-h-[90vh] lands below ~770 px. Cause: LabelTemplatePickerModal.tsx uses a flex column with overflow-hidden on the outer modal, the spool list as the flex-1 shrinkable child, and the templates section + footer as fixed siblings below it. The spool list had min-h-[160px], which combined with the default min-height: auto for flex items meant the spool list couldn't yield space when the modal was tight — the templates and footer overflowed the modal's bottom edge and got clipped. First fix (insufficient): min-h-[160px] → min-h-0 on the spool list scroller, which both removes the fixed floor and overrides the implicit min-height: auto. That made the spool list shrink, but on the user's 838 px viewport with browser chrome eating into 90 vh the four stacked templates (~310 px) plus footer still didn't fit, leaving Avery 5160 half-cut and the Cancel button below the modal's clipped bottom edge — elit3ge confirmed the dev build was still broken after that fix. Second fix: the templates section now renders as a responsive grid (grid-cols-1 sm:grid-cols-2 gap-2) so the four buttons pack into a 2×2 grid above the sm breakpoint, trimming ~150 px of vertical inside the modal. Each cell tightens its label/hint to text-sm + truncate (with the full strings reachable via the new title=label — hint on the button so the truncation never hides information), padding shrinks to p-2.5, and the footer's py-3 is dropped to py-2 for a few extra pixels. The earlier min-h-0 on the spool list is kept as a belt-and-braces shrink for any viewport tighter still. Pre-existing on dev since 0.2.4b2 (commit 864e5c99, the original PR #809 that introduced the modal); not a regression from the spoolman-inventory rebase. Test: the regression test in LabelTemplatePickerModal.test.tsx is upgraded to pin the new structural shape — the templates container has grid + grid-cols-1 + sm:grid-cols-2 and exactly 4 child buttons, plus the existing assertions that all 4 template names + the Cancel button render and the spool list scroller still has min-h-0 with no fixed min-h-[…] literal. So a future refactor that drops the grid and reintroduces stacked rows fails CI.requirements.txt floor raised from >=0.0.26 to >=0.0.27. python-multipart is the multipart/form-data parser FastAPI uses for UploadFile body parsing, so it sits on every Bambuddy upload path (3MF/STEP/STL upload, label-template imports, OIDC certificate upload, backup restore, etc.). The advisory is a parser-side issue against malformed multipart input; Bambuddy doesn't expose unauthenticated upload endpoints (every multipart route is gated on either Permission.LIBRARY_UPLOAD / SETTINGS_UPDATE / INVENTORY_UPDATE), so blast radius is bounded to authenticated callers — but the bump is mechanical and the floor was already loose, so no reason to wait.tailscale_disabled toggle was that flipping it on would obtain an LE cert via tailscale cert so users wouldn't need to import Bambuddy's CA into the slicer. End-to-end testing exposed that this was always going to fail: BambuStudio and OrcaSlicer both refuse hostname input in the Add Printer dialog (IP-only), and — more fundamentally — their printer-MQTT trust path validates only against the bundled BBL CA store (printer.cer), not the system trust store. Confirmed against ClusterM/open-bambu-networking's clean-room reimplementation: mosquitto_tls_set(BBL_CA) + mosquitto_tls_opts_set(verify_peer=1) + mosquitto_tls_insecure_set(true) — chain validation against BBL CA only, hostname check intentionally skipped (because Bambu's printer cert CN is the device serial, not an IP/hostname). LE-issued certs don't chain to BBL CA, so the slicer rejects with the well-known "-1" before any hostname/IP logic runs. The cert-import step is unavoidable; the LE provisioning was dead code for slicer connections. What stays: the toggle, the /virtual-printers/tailscale-status route, the docker socket mount, and the host-level Tailscale information surfaced on the VP card (IP + MagicDNS hostname + copy button) so users know what to paste into the slicer when they pick the Tailscale interface from the bind_ip dropdown. Tailscale's role is now strictly network reach — private WireGuard tunnel to the VP from any tailnet device, no port forwarding — exactly the same trust burden as LAN. What goes: provision_cert / ensure_cert / cert_needs_renewal and the daily renewal task / restart-on-renewal plumbing on the manager (_cert_renewal_task, _cert_restart_task, _cert_renewal_loop, _restart_for_cert_renewal, _cancel_renewal_task, _cancel_restart_task); the tailscale_fqdn field surfaced via VP status (cert side-effect); the tailscale_not_available 409 guard on toggle-enable in both routes/virtual_printers.py and routes/settings.py (toggle is informational, daemon presence doesn't block flipping it); CertificateService.{ts_cert_path, ts_key_path, use_tailscale_cert} and the LE cert files on disk (virtual_printer_ts.{crt,key} left in place per-VP — harmless residue, can be deleted manually). The tailscale_disabled DB column is kept as the persisted toggle state. Tailscale FQDN/IP on the VP card is now sourced from the existing /tailscale/status endpoint (host-level) rather than from per-VP cert provisioning side-effect — the data is the same regardless of which VP you're looking at, since each host has one Tailscale identity. Wiki, README, and i18n copy updated across all 8 locales to drop the "no cert import needed" framing — toggle's helper text now says it surfaces the Tailscale address and that CA import is unchanged. Tests: test_tailscale.py reduced to the surviving get_status cases (binary missing, command fails, success, empty DNSName, malformed JSON); test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change rewritten as test_sync_from_db_does_not_restart_on_tailscale_toggle (toggle is informational — remove_instance must NOT be called when only tailscale_disabled changes); test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI collapsed to a single TestVirtualPrinterTailscaleToggleAPI::test_toggle_does_not_consult_tailscale_daemon that asserts both directions succeed and get_status is never called. Frontend VirtualPrinterCard.test.tsx mock now stubs getTailscaleStatus and the FQDN-copy block drives the FQDN through that query rather than VP status.extra_colors set), brand + material, the spool's own name, the spool ID (the field bsaunder flagged as the most-needed for "find spool 7 in my closet" identification), and a QR code that deep-links to /inventory?spool=<id> so a phone scan jumps straight back to that spool's row in Bambuddy. The box-size template additionally surfaces the storage location field. Architecture: backend/app/services/label_renderer.py is a pure-Python renderer using ReportLab (no headless browser, no system libs) and qrcode (already a dependency); the QR target uses the configured external_url setting if present so phone scans reach the right hostname, otherwise falls back to the request's own scheme+host. Renderer is fully decoupled from the SQLAlchemy model — input is a LabelData dataclass list — so the same code path serves both the local DB inventory and the Spoolman-backed inventory once the dedicated UI lands. Two endpoints: POST /inventory/labels (local) and POST /spoolman/labels (Spoolman-backed; fetches via the existing client and filters in-memory). Both gated on Permission.INVENTORY_READ, both cap requests at 500 spools per call to bound rendering time, both stream application/pdf directly. Why server-side and not browser print? Server-side gives consistent output across browsers, Avery sheet templates that align to <0.1 mm (browser print scaling drifts 2–3 mm per page), one-click "download all 30 selected as one PDF", no print-dialog header/margin fiddling, and reproducible output for support — at the cost of one new pure-Python dep and ~250 lines of layout code. Out of scope for V1: direct-to-label-printer drivers (Dymo / Brother / Zebra ZPL — each is its own multi-week project, follow-up issue per vendor if demand surfaces), user-customizable HTML/CSS templates / template DSL (the four built-ins cover the use case bsaunder articulated; templating engines are where this kind of feature usually drowns), and the "global label mixin for spools/projects/printed parts" framework Keybored02 sketched (right direction for a future feature, not for V1). On the dev branch the local-mode UI is wired; the Spoolman-mode UI defers to the in-flight feature/spoolman-inventory-ui branch where the unified Spoolman picker lives. Tests: 15 unit tests in test_label_renderer.py (each template produces a valid PDF, empty input returns valid empty PDF, unknown template raises, multi-colour swatch survives 4+ stops, missing optional fields don't crash, malformed rgba falls back to grey, long strings are truncated not overflowed, sheet templates paginate when count exceeds one sheet, QR-bearing PDFs are noticeably larger than QR-less ones); 11 integration tests in test_labels.py (both modes produce PDFs, all four templates succeed, unknown template / empty list / unknown spool ID rejected with the right code, request order preserved into the renderer so Avery sheets match the on-screen list, Spoolman path returns 400 when disabled / 503 when unreachable / 404 when spool missing / 200 with the expected content-type when it works, request body capped at MAX_LABELS_PER_REQUEST); 7 frontend tests in LabelTemplatePickerModal.test.tsx (modal absent when closed, four templates rendered, singular vs plural subtitle, spoolmanMode false routes to local API and vice versa, neither API called when the other mode is active, error path keeps the modal open so the user can retry). All 8 locales get the new inventory.labels.* key set with English strings (other locales seeded with English copy pending native translation, matches the project's existing flow for newly-added user-facing features).push_status and info.get_version response from Bambuddy's existing per-printer MQTT subscription (no second session on the printer — firmware in-flight budget unaffected, see #1164). The VP's _send_status_report returns a near-byte-identical copy of the real push with only the upload-state-machine fields (sequence_id, command, msg, gcode_state, gcode_file, prepare_percent, subtask_name) overridden under our control, so BambuStudio's Send pre-flight sees exactly the same shape as a direct-to-printer connection. Command responses (extrusion_cali_get, AMS write acks, xcam responses) are fanned out raw — they carry sequence_ids the slicer is waiting on. Slicer-issued commands forward to the real printer except print.project_file / gcode_file, which are still answered locally because the file lives on Bambuddy. Field-shape gotchas worth remembering: (1) Real Bambu printers wire-format push_status JSON with indent=4 (32 254 bytes for an idle H2D push, vs 14 268 bytes compact) — BambuStudio's Send pre-flight rejects compact JSON silently, so _publish_to_report was switched to json.dumps(payload, indent=4). (2) net.info[*].ip (little-endian uint32, e.g. 192.168.255.133 → 2248124608) is the FTP destination IP BambuStudio uses for "Send to Printer storage" — it overrides anything else, including the URL hosts the rest of MQTT advertises. The bridge rewrites this to the VP's bind IP on cache, otherwise the slicer FTPs straight to the real printer and bypasses Bambuddy entirely (symptom: "Failed to send" with zero inbound FTP connections on the VP — debug-by-tcpdump if anyone hits it again). (3) upgrade_state.sn and any other nested-dict sn matching the target serial are rewritten to the VP serial; AMS-hardware serials (n3f/0.sn etc.) are left alone — those identify physical AMS units, not the device. (4) ipcam.rtsp_url is left unchanged: BambuStudio overrides the URL host with the device IP it bound on (the VP), so the slicer hits the VP's :322 RTSPS port — not the printer's directly. (5) For the slicer's RTSPS to reach the printer, the VP gets a raw TCPProxy on <bind_ip>:322 → <printer_ip>:322 (same approach proxy mode uses; cap_net_bind_service was already in the systemd unit for FTP :990). (6) extrusion_cali_get is forwarded — answering it locally hides the user's stored k-profiles. Setup nuance for camera: because the slicer authenticates against the printer's RTSPS with whatever access code is in its profile, the VP's access code must match the target printer's access code for the camera path to authenticate. This is a one-time configuration step (Settings → Virtual Printer → set access code = target printer's LAN code, then re-add the VP in Bambu Studio / Orca Slicer). MQTT and FTP work either way; only camera needs the match because RTSPS auth happens between the slicer and the real printer's broker. Tested e2e with both BambuStudio and OrcaSlicer against H2D (dual-nozzle, AMS 2 Pro + AMS HT) and X1C (single-nozzle, AMS) across all three non-proxy modes (Immediate / Review / Print Queue) — sync, send, k-profile lookup, AMS configuration from slicer, and live camera all work. Files: new backend/app/services/virtual_printer/mqtt_bridge.py (caches push_status / get_version, forwards slicer commands, fans out command responses, rewrites identity fields including net.info[*].ip LE uint32); bambu_mqtt.py gains register_raw_message_handler / unregister_raw_message_handler / publish_raw so the bridge can subscribe to Bambuddy's existing per-printer paho subscription without opening a second session; mqtt_server.py switches _send_status_report and _send_version_response to cached-as-base when the bridge has data, falls back to the original synthetic stubs otherwise; manager.py wires the bridge + a raw TCPProxy for RTSPS into start_server for non-proxy modes whenever a target printer is configured. 25 new tests in test_vp_mqtt_bridge.py pin the contract: lifecycle, push_status caching, serial / IP rewriting, get_version-modules cache, selective fan-out (only command responses, never push_status itself), wire format must use indent=4, routing of slicer-issued commands (project_file / gcode_file local; everything else forwarded), and the IP-encoding helper against captures from real H2D pushes. Proxy mode is untouched — SlicerProxyManager still owns its own MQTT/FTP/RTSP/Bind/Aux proxies in proxy mode and never instantiates SimpleMQTTServer or MQTTBridge.bambu_mqtt.py (reverse-engineered from BambuStudio captures, including the H2D dual-extruder right-external case captured fresh during this work) but were unused — there was no HTTP route and no UI. Net effect: every Load / Unload had to happen on the printer touchscreen, and external-spool users on dual-nozzle H2D had no way to drive Ext-R from the desktop at all. Backend: new POST /printers/{id}/ams/load?tray_id={int} and POST /printers/{id}/ams/unload, both gated on Permission.PRINTERS_CONTROL. The load route validates tray_id ∈ {0..15, 254, 255} (AMS slots, single-external/Ext-L, Ext-R respectively) and returns a human-readable target in the success message ("AMS 0 slot 1", "external spool", "Ext-R") so the UI toast tells the user which spool the printer is now feeding from. MQTT primitive update: ams_load_filament gains a third encoding branch for tray_id=255 matching the BambuStudio capture verbatim — ams_id=255, slot_id=0 (the right-extruder index, not a slot index — Bambu's load command on dual-extruder externals encodes the destination extruder, not the source slot), target=255, and curr_temp = tar_temp = right-nozzle temp (read from state.temperatures["nozzle_2"], falling back to 215 °C if the right nozzle is cold or unknown — the printer rejects nonsensical temps, so a warm fallback is safer than -1). The existing tray_id=254 branch is preserved verbatim (slot_id=254, curr/tar=-1) since that came from a single-extruder capture and is known to work; no risk of regression on existing single-external setups. UI: the existing AMS slot popover (the one with "Re-read RFID") gains two new entries — "Load" (posts tray_id = ams.id * 4 + slotIdx) and "Unload" (no params, global on the currently-loaded slot). The external spool slot — which had no popover at all before — gets one with the same Load + Unload entries, and on dual-nozzle H2D each external slot (Ext-L tray_id=254, Ext-R tray_id=255) drives its own extruder. The menu is hidden while state === 'RUNNING' (parallels the existing RFID re-read gating). i18n: printers.ams.load, printers.ams.unload, plus four new toast strings (loadInitiated, unloadInitiated, failedToLoad, failedToUnload) added to all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copy pending native translation (matches the project's existing flow for newly-added user-facing features). 16 new tests pin the contract: 5 unit tests in test_bambu_mqtt.py::TestAmsLoadFilamentEncoding (AMS slot encoding, Ext-L preserves legacy capture, Ext-R uses the new captured shape with actual right-nozzle temp, Ext-R falls back to 215 °C when cold, disconnected client doesn't publish); 11 integration tests in test_printers_api.py::TestAMSLoadUnloadAPI (load: invalid tray_id 400, not-found 404, not-connected 400, AMS slot success with derived ams_id*4+slot math, Ext-L success, Ext-R success, MQTT failure 500; unload: not-found, not-connected, success, MQTT failure 500); 4 frontend tests in PrintersPageAmsLoadUnload.test.tsx (Load posts the right tray_id, Unload posts with no params, menu hidden while RUNNING, external spool's tray_id=254 round-trips through the route).API keys can read Bambu Cloud presets on the owner's behalf (#1182, reported by @turulix) — Tim is building a fully automated headless slicing pipeline against Bambuddy's API and hit the wall flagged in the previous round of cloud-auth work (#665): /cloud/* routes resolve cloud_token per-user from User.cloud_token, but the auth gate (require_permission_if_auth_enabled, auth.py:856) returned None for API-keyed requests, so the route fell back to the global Settings-table token, which only carries a value in auth-disabled deployments. Net effect on auth-enabled deployments: API keys reached the gate just fine, then /cloud/filaments always saw user=None, called get_stored_token(db, None) against an empty Settings table, and returned 401 / empty results — no path to read the slicer presets, filament catalogue, or device list that a CLI workflow needs. The data model treated API keys as standalone tokens with no owner (APIKey had id, name, key_hash, scope flags, and printer_ids — no user_id), so even if the gate wanted to delegate the cloud lookup, there was no User to delegate to. The fix: make API keys carry an owner, route /cloud/* lookups through that owner, and gate the new capability behind an explicit opt-in scope so existing automation doesn't gain cloud-read access on upgrade. Concretely: (1) APIKey gains user_id (FK to users.id, ON DELETE CASCADE — Postgres enforces, SQLite plus an explicit DELETE FROM api_keys WHERE user_id = ? in the user-delete route since SQLite ships FK enforcement off; the project's existing pattern at users.py:397-406 for created_by_id cleanup) and can_access_cloud (BOOLEAN DEFAULT 0 — opt-in, never set on legacy rows). (2) The auth gate now returns the owner User when it validates an API key with user_id set, so /cloud/* routes naturally resolve user.cloud_token the same way they do for JWT-authed sessions. Permission semantics are preserved — API keys still bypass the per-route permission check (their scopes live on the row itself), the User return is only so cloud-aware routes can read per-user state. Legacy ownerless keys (user_id IS NULL) keep returning None, stay anonymous, and continue working against every non-cloud route exactly as before. (3) A router-level dependency on the /cloud/* APIRouter enforces three independent fences for API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with "recreate it from Settings → API Keys" — explicit recreate path rather than silently degrading), can_access_cloud=True (otherwise 403 with "Enable 'Allow cloud access' on the key"), and build_authenticated_cloud returning a service (otherwise 401 with the existing token-not-set error — unchanged for JWT flow). The router-level dep duplicates the API-key validation done by the regular auth gate (router-level deps run before route-level deps in FastAPI, so request.state isn't populated yet) — the cost is one extra SELECT FROM api_keys per cloud request, bounded and cheap with the key_prefix index. (4) The create route stamps user_id = current_user.id from the creator and rejects can_access_cloud=True when auth is disabled (no per-user cloud_token storage exists in that mode — fail loudly at create time rather than silently producing a non-functional key). PATCH route rejects flipping can_access_cloud to True on a legacy ownerless key for the same reason — force recreate. (5) APIKeyResponse exposes user_id so the UI can show ownership at a glance: a "Cloud" badge for cloud-enabled keys and a "Legacy" badge with hover tooltip ("Created before per-user ownership; recreate to use cloud access") for ownerless rows. The form gains an "Allow cloud access" checkbox, default off. Migration: two idempotent ALTER TABLE api_keys ADD COLUMN (user_id INTEGER REFERENCES users(id) ON DELETE CASCADE and can_access_cloud BOOLEAN DEFAULT 0) plus an index on user_id for the auth-gate's owner→keys lookup that runs on every API-keyed request. i18n: 5 new keys (settings.cloudAccess, settings.cloudAccessDescription, settings.cloudBadge, settings.legacyKey, settings.legacyKeyTooltip) added to all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copies pending native translation (matches the project's existing flow for newly-added user-facing features). 9 backend integration tests in test_api_key_cloud_access.py: create stamps owner + cloud flag, defaults off when not asked for, rejected when auth disabled (no per-user storage), PATCH rejected on legacy keys; cloud router rejects legacy keys with the recreate copy, rejects owned-but-no-cloud-flag keys with the enable-cloud-access copy, lets owned-and-flagged keys through with owner's cloud_token in the response, JWT callers unaffected (gate is no-op for non-API-keyed); user-delete CASCADEs the API keys via the explicit DELETE in the route. 2 frontend SettingsPage tests pin the badge rendering matrix (Cloud badge present on can_access_cloud=true, Legacy badge present on user_id=null, neither rendered on a normal owned non-cloud key) and the create-form contract (toggling "Allow cloud access" results in can_access_cloud=true in the POST body). Permission semantics for the new fence are the only behavioural change for existing API keys: keys created before this release become "legacy" rows and are rejected at /cloud/* with the recreate message; every other endpoint they were used against — queue, status, control — is untouched.
Home Assistant addon detection — Settings → Updates and the in-app update banner now defer to the HA Supervisor (#1167, reported by @Spegeli) — Bambuddy already shipped HA_URL/HA_TOKEN env-var support specifically labelled "for HA Add-on deployments" (#283) and a community-maintained HA addon (hobbypunk90/homeassistant-addon-bambuddy) exists upstream, so an HA-supervised installation is a real first-class deployment shape. Until now though, the update UI didn't know about it: HA addon users got the same "Update available!" banner as everyone else and, if they clicked through to Settings, saw the docker-compose snippet ("docker compose pull && docker compose up -d") which they cannot run from inside an HA addon container — that's the Supervisor's job. Detection uses the canonical signal: HA Supervisor injects SUPERVISOR_TOKEN into every addon container, and that variable is not set in any other environment. A new _is_ha_addon() helper in backend/app/api/routes/updates.py flips a request-level boolean which /updates/check surfaces as is_ha_addon: bool + an extended update_method: 'git' | 'docker' | 'ha_addon' enum. The check is checked before Docker on /updates/apply because HA addons are Docker containers — checking docker first would mis-classify them and serve the wrong message; the response also keeps is_docker: true alongside is_ha_addon: true so older frontend bundles still hit a managed-deployment branch (degrading to the Docker UX) instead of rendering an in-app Install button that can't work. Frontend branches identically: SettingsPage.tsx's update card checks is_ha_addon first and renders "Updates are managed by the Home Assistant Supervisor. Open Settings → Add-ons → Bambuddy in Home Assistant to install the new version." in place of the docker-compose hint; Layout.tsx's update banner is suppressed entirely for HA addons since the HA Supervisor's own update notification already surfaces the new version natively in the HA UI and a duplicate Bambuddy banner would just be noise that links to a page that says "go to HA". Plain Docker deployments are unaffected — the existing docker-compose hint and the in-app banner still render the same way they did. Localised across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) with full translations of the new settings.updateViaHomeAssistant string. 6 new tests pin the contract: 3 backend unit tests for _is_ha_addon() (env var present → true, absent → false, empty string treated as unset to guard against shells that export it empty), 1 backend integration test for the HA-precedes-Docker rejection on /updates/apply (asserts the message says "Home Assistant" and not "Docker Compose"), 2 backend integration tests for /updates/check covering the HA-addon branch (update_method == "ha_addon", both flags true) and the plain-Docker branch (is_ha_addon: false, update_method == "docker"); 2 frontend SettingsPage tests pin the mutually-exclusive UI rendering (HA branch shows the HA copy and not the docker-compose snippet; Docker branch shows the snippet and not the HA copy, neither shows the Install button); 2 frontend Layout tests pin the banner suppression for HA and its retention for plain Docker.
OIDC auto-created users now get readable usernames and land in a configurable group (#1173) — Two improvements to the OIDC auto-create flow: (1) Username derivation: Bambuddy now derives the username from preferred_username, then name, before falling back to the opaque provider_sub[:30]. Each candidate is sanitized independently — alphanumeric plus ./-/_, whitespace collapsed, deduplication suffix appended on collision — so a value that strips to empty (e.g. "!!!") correctly falls through to the next option rather than silently producing "oidcuser". (2) Default group: each OIDC provider gains a default_group_id field. When set, auto-created users are placed in that group; when unset, the existing "Viewers" fallback is preserved, so behaviour is unchanged for existing deployments. The column is nullable with ON DELETE SET NULL; SQLite does not enforce FK constraints here, so a deleted configured group falls through to Viewers at runtime. default_group_id is validated on create/update (422 on a non-existent group). Exposed in the OIDC settings form as a group dropdown. Limitation: to clear a configured default group, delete the group or select a different one — explicit reset-to-null is not currently supported.
Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed (#1162, reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS info bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to 0xE ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building ams_extruder_map (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by extruderId === nozzle_id to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out every loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — print.device.fila_switch — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: in[track] = currently fed slot (-1 = empty) and out[track] = extruder this track terminates at. The fix surfaces this through a new FilaSwitchState dataclass on PrinterState (installed, in_slots, out_extruders, stat, info) and the equivalent FilaSwitchResponse Pydantic schema on the GET /printers/{id}/status route. Frontend (useFilamentMapping.ts + FilamentMapping.tsx) skips the per-extruder filter when printerStatus.fila_switch?.installed === true so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — [L] or [R] — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — fila_switch == null keeps the existing behaviour). 4 backend tests in test_bambu_mqtt.py::TestFilamentTrackSwitchDetection (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in useFilamentMapping.test.ts (FTS-active drops the nozzle filter; explicit fila_switch: null keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — installed flips on the presence of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in in_slots.
Docker permission errors on /app/data/virtual_printer and similar paths — root-owned volumes / bind-mount sources no longer break virtual printer setup (#1211 follow-up; same shape as multiple previous user reports) — Two related failure modes have been biting Docker users repeatedly: (1) Docker named volumes are created by the daemon as root:root and the previous chmod 777 /app/data Dockerfile workaround only covered the named-volume root, so subdirs Bambuddy creates at runtime (virtual_printer/uploads, virtual_printer/certs, etc.) inherited the wrong ownership when the container ran as 1000:1000; (2) the shipped docker-compose.yml ships ./virtual_printer:/app/data/virtual_printer uncommented, and dockerd creates a missing bind-mount source on the host as root before the container starts — leaving the host directory unwritable by uid 1000 inside the container even though the named volume above it had the chmod-777 workaround. Symptom either way: [Errno 13] Permission denied: '/app/data/virtual_printer/uploads', no virtual printer ever starts, "VP doesn't work" support reports follow. Fix: new deploy/docker-entrypoint.sh runs as root, normalises ownership of /app/data and /app/logs (and /app/data/virtual_printer when bind-mounted) to PUID:PGID (default 1000:1000, overridable via env), then drops to that uid via gosu before exec'ing uvicorn. The chown is gated behind a top-level ownership check so subsequent restarts skip the recursive traversal entirely (no multi-second startup penalty on multi-GB archive dirs). A sentinel .bambuddy file in each data path prevents Docker from re-syncing image directory metadata on every mount (otherwise empty volumes have their ownership reverted from the image on each restart, defeating the idempotency). When the container is started with an explicit user: directive in compose or --user on docker run, the entrypoint detects it isn't running as root and falls through to direct exec without modifying ownership — preserving compatibility with users who pin a specific uid. Compose template changes: the user: "${PUID:-1000}:${PGID:-1000}" line is removed (entrypoint owns privilege drop now); PUID / PGID env vars added with the same defaults; ./virtual_printer:/app/data/virtual_printer bind mount commented out by default with a clearer explanation of when it's actually needed (only when sharing the VP CA certificate with a co-located native install, which most Docker-only users don't have). Existing users with that bind mount uncommented continue to work — the entrypoint chowns the host-side directory through the bind mount the first time it sees the wrong ownership, fixing #1211 specifically. Tested end-to-end against four scenarios on a clean rebuild: (a) named volume only with default PUID/PGID; (b) explicit --user 1000:1000 override (entrypoint falls through); (c) custom PUID=1500; (d) legacy stale root-owned volume contents from a pre-fix install (gets normalised on first start). Idempotency verified: chown messages appear on first start, subsequent starts are silent.
Backup restore silently lost most data — settings reverted to defaults, ~most printers/archive rows missing (#1211, reported by @Carter3DP; same shape as previously-closed #668) — Restoring a settings backup ZIP appeared to succeed but the user found their energy_cost_per_kwh reverted to the 0.15 default (defined in main.py:3457), 7 of 8 printers gone, 1 GB of archive files on disk but only 1 archive row in the database. #668 was closed in March without an actual fix — that user happened to make it work by rolling back to a stable release, which masked the bug; same shape resurfaces here on a single (consistent) version. Cause: the live database runs in WAL mode (PRAGMA journal_mode = WAL in database.py:19). The original restore endpoint used shutil.copy2(backup_db, db_path) after engine.dispose(). Two things conspired to make this unsafe: (1) anything the fresh container wrote between startup and the restore call — seed_default_groups, init_db() migrations, background heartbeat writes — sits in bambuddy.db-wal with valid checksums, and engine.dispose() doesn't checkpoint it; (2) FastAPI's dependency injection keeps the route handler's own db: AsyncSession = Depends(get_db) session checked out across engine.dispose() (per SQLAlchemy docs, dispose only closes pooled — not checked-out — connections), so the WAL inode is held open through the whole restore. After shutil.copy2 rewrote the main DB inode in place, SQLite's WAL recovery on the next init_db() happily re-applied the stale frames on top of the restored content, partially clobbering it with fresh-install state. Initial fix attempt of "delete the WAL/SHM/journal sidecars before the copy" turned out to be insufficient — verified experimentally that the still-open request session reads the unlinked sidecars via held fds and bleeds the WAL state back into the new file when it eventually closes. Real fix: replace the file copy with SQLite's online backup API (src_conn.backup(dst_conn)). The page-by-page protocol opens both DBs as proper SQLite connections, acquires the right locks, and routes new pages through the destination's own WAL — concurrent open sessions see their own transactional snapshot until they close (transaction isolation) but can't corrupt the restored state. Verified via 6 regression tests in backend/tests/unit/test_restore_sqlite_wal_safety.py: the buggy shutil.copy2 path is pinned (the test asserts the bug manifests under the un-checkpointed-WAL condition, so a future "small simplification" can't silently re-introduce it); the production src_conn.backup(dst_conn) path returns the user's restored values exactly under the same bug condition; the no-WAL-frames case (fresh container, restore as the very first action) round-trips cleanly; and the page-protocol parametrised test runs at 1, 100, and 1000-page DB sizes so a regression at any one size surfaces. PostgreSQL path (_import_sqlite_to_postgres) is unchanged — that's row-by-row already and was never affected.
formatTimeOnly tests failed under non-:-separator locales (#1213, reported by @maugsburger) — Running the frontend test suite under LC_ALL=en_DK.UTF-8 (or any locale whose toLocaleTimeString uses a separator other than :) failed two tests in frontend/src/__tests__/utils/date.test.ts: formats time with 12h format (expected 02.30 pm to match /2:30|02:30/) and formats time with 24h format (expected 14.30 to contain 14:30). The implementation is correct — formatTimeOnly calls date.toLocaleTimeString([], …) which by design respects the user's locale, so a Danish-English user genuinely should see 02.30 pm in the UI. The tests just hard-coded the : separator. Fix: test assertions now use \D+ (any non-digit, one or more) for the separator: expect(result).toMatch(/\b0?2\D+30\b/) and expect(result).toMatch(/\b14\D+30\b/). Tests the actual contract — "the function returns hours and minutes, separated somehow" — without coupling to a specific separator that varies by locale (enDK uses ., some en* locales use a narrow no-break space at U+202F, most others use :). Verified passing under en_DK.UTF-8, en_US.UTF-8, and de_DE.UTF-8. Audited every other toLocaleTimeString/toLocaleString call site in the test suite — no other places hard-code separator characters; formatETA, formatDateInput etc. assert via toBeTruthy() or check translated content.
SpoolBuddy kiosk screen-blank timeout setting was ignored after the first save (reported by maziggy) — Picking a new "Screen Blank Timeout" in SpoolBuddy Settings → Display didn't change the actual blanking behaviour: whatever value was active when the kiosk last booted continued to fire — a user who started with the 10 m preset and then switched to 1 m, 5 m, or "Off" still saw the screen blank at 10 m forever. Cause: blanking is driven by swayidle, started once by spoolbuddy/install/spoolbuddy-idle.sh at labwc autostart with the timeout passed as a command-line argument (swayidle -w timeout $T 'wlopm --off' resume 'wlopm --on'). The script fetched blank_timeout from the backend exactly once at startup and swayidle has no runtime control surface for changing its timeout. The Python daemon's display.set_blank_timeout() updated an in-memory variable on the daemon side that was only used for daemon-side idle bookkeeping (tick() log-line) and never reached swayidle, so UI changes were silently discarded until the next kiosk restart. Documented as such in the daemon's docstring (display_control.py:5: "swayidle is the sole authority on screen blanking") — the architecture predicted the bug, the UX never matched. Fix: the wake FIFO at /tmp/spoolbuddy-wake now carries a second message in addition to wake: reload-timeout N. The daemon writes it whenever set_blank_timeout() is called with a value that differs from the current one (the very first call is suppressed because the watchdog already fetched the same value at its own startup — signalling there would just thrash swayidle on every cold start). The watchdog script's FIFO loop is restructured around start_swayidle / stop_swayidle helpers and a single case statement that dispatches on the message: wake → wlopm --on + arm a re-blank at the current timeout; reload-timeout N → kill the running swayidle, set TIMEOUT=$N, restart swayidle, and wlopm --on so the user sees the change took effect even if the screen was already blanked. The script de-dupes too — a reload-timeout N whose N matches the current value is a no-op, so the daemon's local de-dupe and the script's de-dupe both guard against thrash. Going from any positive timeout to 0 ("Off") correctly stops swayidle and never restarts it, going from 0 to a positive value starts a fresh swayidle — both work without a kiosk restart. The script's main loop opens the FIFO read+write (exec 3<>"$WAKE_FIFO") so the bash read never sees EOF when the daemon momentarily disconnects between writes (without that, the loop would exit the first time the daemon closed its write end). A cleanup trap on TERM/INT/HUP stops swayidle, removes the FIFO, and exits cleanly. 7 new tests in spoolbuddy/tests/test_display_control.py::TestDisplayControlFifoMessages pin the daemon side of the protocol against a real FIFO in tmp_path: wake() writes the literal wake\n line; first set_blank_timeout is suppressed (script already has the right value); subsequent change emits reload-timeout N\n; identical-value calls don't signal; transitioning to 0 emits reload-timeout 0\n (covers "user picks Off after enabling"); negative inputs are clamped to 0 in the signal payload; missing-FIFO writes are silent no-ops (kiosk-not-running case). Also handles the SpoolBuddy 0 schema default — the first set_blank_timeout(0) call from a fresh daemon doesn't signal (init suppressed) so no spurious thrash on a never-configured device.
Archive 3MFs (and library file bytes) silently deleted from disk on every print completion (#1212, reported by @abbasegbeyemi; matches private "file disappeared overnight" reports) — Reprint and View G-code on a freshly-completed archive returned 404 with no log line explaining why; the DB row was intact, the archive grid kept showing the entry, but archive.file_path pointed at a path that no longer existed on disk. Same shape independently reported by a daily-build user whose .gcode.3mf "disappeared by itself overnight" between Saturday's print and Monday morning's reprint attempt. Root cause was a regression introduced by #1166's cover-cache pre-population: the dispatch sites in background_dispatch.py:692, background_dispatch.py:896, and print_scheduler.py:1897 started caching the live archive copy (and library file bytes for the Direct-Print flow) in the shared 3MF download cache so the /cover endpoint could skip a redundant FTP transfer to the printer mid-print. The cache itself was originally designed for transient downloads under archive_dir/temp/ and clear_3mf_cache(printer_id, delete_files=True) — called from on_print_complete to keep that temp dir from accumulating — happily unlink()'d every cached path. Pre-#1166 every cached path was a temp file, so deletion was correct. Post-#1166 the cleanup was destroying user data: every print → archive 3mf cached → on print complete clear_3mf_cache walks the cache → path.unlink() on the actual archive copy. The Path.exists() guard inside _maybe_unlink masked the failure: the file existed at unlink time, so no exception, no warning, just silent destruction. The DB row remained, so the UI listing didn't change — only when the user tried to act on the archive (reprint / view-gcode / re-export) did the missing file surface as a 404. Affected every daily build since 889c8bd8 (Apr 29). Fix: clear_3mf_cache._maybe_unlink in backend/app/services/bambu_ftp.py now refuses to unlink() any path outside archive_dir/temp — the cache dict is still cleared either way (so re-cache logic continues to work and the cover endpoint still hits a fresh path on the next print), only the on-disk delete is gated. Persistent locations — archive/<printer_id>/..., archive/unassigned/... (VP-archived prints with printer_id=None), library_files/..., and any is_external library mount — survive intact. The dispatch sites that cache those paths are unchanged: it's correct for /cover to read straight from the live archive copy and avoid the redundant 36 MB FTP transfer; the only bug was the cleanup branch treating all cached paths as transient. Regression test test_clear_does_not_delete_persistent_files in test_bambu_ftp.py pins the contract end-to-end: an archive 3mf at archive/1/.../...gcode.3mf, a library 3mf at library_files/..., and a temp 3mf at archive/temp/... are all cached for the same printer; after clear_3mf_cache(1) runs, all three cache entries are dropped from the dict (so the cache state is consistent), but only the temp file is unlinked from disk — the archive and library files still exist. Two existing cache tests (test_clear_by_printer_scoped, test_clear_without_deleting_files) updated to put their fixtures under archive_dir/temp since that's now the only path the cleanup will touch. Damage: users on daily builds since Apr 29 with a print → wait for completion → reprint or view-3mf later workflow have been silently losing archive copies. Recovery for individual users: re-import the source 3mf from your slicer / NAS, or re-archive from the printer's FTP if the file is still there. Going forward the bytes are safe.
MakerWorld P2S 3MFs failed to slice with "Param values in 3mf/config error: -1 not in range" (#1201, reported by @inorichi) — Slicing any MakerWorld model sliced for the P2S (e.g. https://makerworld.com/en/models/1958872) bombed with Slicer process failed (exit code 238) and stderr listing raft_first_layer_expansion: -1 not in range [0.0, 3.4e+38] and tree_support_wall_count: -1 not in range [0.0, 2.0]. Root cause: BambuStudio writes "-1" into Metadata/project_settings.config for fields the user wants inherited from the parent process preset — the GUI handles this internally, but the headless CLI (orca-slicer-api / bambu-studio-api sidecar) runs StaticPrintConfig's range validator against the embedded settings before the --load-settings overrides apply, so the sentinel "-1" trips the field's lower-bound check and the CLI exits non-zero before our profile triplet is ever consulted. The slice_with_profiles path failed; the fallback to slice_without_profiles (which uses embedded settings only) also failed because it reads the same project_settings.config and the same validator runs there too. Earlier in the codebase there's a _strip_3mf_embedded_settings function that tried to dodge this by removing the entire project_settings.config (plus model_settings.config, slice_info.config, cut_information.xml); that experiment was reverted because the strip broke StaticPrintConfig initialisation — silent exit-0, no result.json, no stderr, masked by the fallback retry which then produced wrong-printer output without telling anyone (the cautionary comment in library.py:_run_slicer_with_fallback records the lesson). Fix is surgical: new _sanitize_project_settings_sentinels(zip_bytes) opens the embedded config, removes only allowlisted keys when their value is exactly "-1", and re-zips. Allowlist (_PROJECT_SETTINGS_SENTINEL_KEYS) starts with the two from this report (raft_first_layer_expansion, tree_support_wall_count) plus prime_tower_brim_width (a known sentinel cited in the strip-experiment comment block from earlier reports). Other fields — including non-allowlisted keys that happen to hold "-1" (e.g. z_offset set to -1 deliberately by a user) — are left untouched, so a blanket "-1 strip" can't silently corrupt legitimate negative values. The sanitiser runs before both the profile-driven path and the embedded-settings fallback, since both fail on the same input. Defensive fallbacks: returns the original bytes unchanged when the input isn't a valid zip, doesn't contain project_settings.config, has no allowlisted sentinels present, the JSON is malformed, or the config root isn't a dict — so the caller can pass the result on without further checks. Geometry, thumbnails, color, multi-part data, and every other zip entry round-trip byte-identical (the previous full-strip experiment's failure mode can't reoccur). 13 new unit tests in test_project_settings_sentinel_sanitiser.py pin the contract: each allowlisted key removed when value is "-1" (parametrised across the allowlist); multiple sentinels removed at once; allowlisted key with legitimate non-sentinel value ("0") preserved; non-allowlisted key holding "-1" (z_offset) preserved; identity return when nothing needs sanitising; array-form values (per-filament/per-extruder lists) left alone (v1 handles scalar strings only, expand later if needed); other zip entries (model_settings.config, slice_info.config, _rels metadata, geometry) all preserved with byte-identical content; non-zip input passes through; missing project_settings.config passes through; malformed JSON passes through; non-dict JSON root passes through. Adding new sentinel keys: if a future report surfaces another field name in the slicer's <field>: -1 not in range [...] error, add the field to _PROJECT_SETTINGS_SENTINEL_KEYS — the rest of the code stays unchanged.
Archive created with wrong plate metadata when consecutive plates of the same model are printed back-to-back (#1204, reported by @BurntOutHylian) — Print Plate 2 of any multi-plate project, let it complete, then immediately print Plate 1: the resulting archive was named "MyModel - Plate 2" with Plate 2's filament slots and slicer estimate, even though Plate 1 was the print actually running. Root cause was an MQTT lag in the print_start data: the trigger fires on a gcode_file change (bambu_mqtt.py:2781-2786 — the field carrying /data/Metadata/plate_N.gcode, which is plate-specific and always fresh), but subtask_name (model-level, e.g. "MyModel - Plate 2") can still echo the previous job in the same MQTT batch. The FTP candidate list in main.py:1974 is built from subtask_name first, so the previous Plate 2 upload — still resident on the printer's FTP from the just-completed print — got picked up and fed into archive creation. The 3MF parser then read _plate_index=2 from the wrong file's slice_info.config and locked Plate 2's name + estimate + per-slot filament data into the row at creation, with no follow-up to correct. Reporter @BurntOutHylian's diagnosis nailed it: the parser already extracts _plate_index from inside the 3MF (archive.py:154), and parse_plate_id() (printer_manager.py:678) already extracts the plate from gcode_file — those two values just weren't being compared. Fix: new helpers peek_plate_index_in_3mf() (cheap zip read of Metadata/slice_info.config only, returning the plate index) and swap_plate_suffix() (rewrites trailing " - Plate N" or "_plate_N" — both forms appear in real subtask_names, see test_print_start_expected_promotion) in archive.py. After a successful FTP download in _handle_print_start, the new validation block in main.py peeks the downloaded 3MF's plate index, compares against parse_plate_id(filename), and on mismatch retries the FTP fetch with a corrected subtask_name. If the retry finds a 3MF whose plate matches, the wrong file is dropped and the corrected one is used — archive name + estimate + slots all reflect the actual plate. If the retry can't find a matching file (or no swap is possible because subtask_name had no plate suffix to swap), the wrong 3MF is dropped and the existing no-3MF fallback (main.py:2155) creates an archive without metadata; the stale subtask_name is overridden to the corrected one (or cleared so filename wins) so the fallback's print_name at least reflects the right plate rather than locking in a misleading name. The validation only fires when parse_plate_id(filename) returns a value, so single-plate / non-Bambu / cloud-named jobs are unaffected. Defence in depth: the cache eviction is implicit — temp_path.unlink() makes the wrong-file cache entry self-clean on next access via the existing get_cached_3mf evict-on-miss path (bambu_ftp.py:660-664); no separate cache invalidation needed. 17 new unit tests in test_archive_plate_validation.py pin the helpers: peek_plate_index_in_3mf returns the index for a valid 3MF, None for missing slice_info, None for missing index metadata, None for non-zip files, None for missing files, None for non-integer index values; swap_plate_suffix handles the spaced "Plate N" form (capitalised + lowercase + tight-hyphen), the underscored "_plate_N" form (the Box3.0_(2)_plate_5 case from the existing fixture), case-insensitive matching, returns None for names without a recognised suffix, returns None for None input, and preserves separator casing so the corrected name matches what BambuStudio actually uploaded.
SpoolBuddy kiosk screen never blanked while a load cell was producing noisy readings (reported during user testing) — A noisy HX711 / load-cell mount that bounced the reported weight by ≥50 g around its midpoint kept the kiosk display permanently lit. The wake gate in spoolbuddy/daemon/main.py:scale_poll_loop (WAKE_THRESHOLD = 50) checked the absolute change against last_wake_grams and, on every trip, advanced last_wake_grams to the new noisy reading — so the next bounce back also exceeded the threshold, fired display.wake() again, and the screen never stayed off long enough for swayidle's wlopm --off HDMI-A-1 to mean anything. Symptom in the field: ~3–30 s between Wake signal sent via FIFO log lines, exactly correlated with the bigger noise spikes, screen flicker-blanking and immediately turning back on. Diagnosis from a real device's journalctl -u spoolbuddy.service: scale/reading POSTs every ~1 s (REPORT_THRESHOLD=2 g, so the load cell was reporting ≥2 g changes constantly) interleaved with periodic wake signals. Fix: the wake gate now requires the scale's stable flag (True only when consecutive readings agree within 2 g over a 1 s window — already produced by ScaleReader.read() and previously only forwarded as telemetry to the backend). Unstable noise can no longer fire wake AND can no longer poison last_wake_grams, since the threshold check + the assignment are both gated on stable. Real spool placements / removals produce a settled post-event reading and continue to wake the screen as intended. 3 new regression tests in spoolbuddy/tests/test_main.py::TestScalePollLoopWakeGating: noisy ±60 g unstable readings never wake (the original bug); a settled >50 g jump wakes; a noise burst between two settled readings doesn't poison last_wake_grams (asserts the second stable wake still fires from the original baseline rather than the noisy peak).
Print-complete notification reported the slicer's pre-print estimate instead of the actual elapsed time (#1198, reported by @BurntOutHylian) — _background_notifications in main.py:3434 built archive_data for the completion notification with print_time_seconds (the slicer's estimate parsed from the 3MF at archive creation), and notification_service.py:909-910 then formatted that field straight into the {{duration}} template variable. Net effect: a print cancelled 2 minutes into a 3-hour estimate told the user "duration: 3h" — wrong by orders of magnitude for any cancellation, abort, slow first layer, or any print whose actual elapsed diverged from the slicer's guess. The companion field actual_filament_grams was already scaled by progress for partial prints (line 3445), so filament was right while time was wrong. The print_start notification uses a separate {{estimated_time}} variable (line 838), so {{duration}} semantically should always have meant "actual elapsed" — it was just being read from the wrong source. Two-part fix: (1) main.py:3434 now computes actual_time_seconds = int((archive.completed_at - archive.started_at).total_seconds()) from the persisted timestamps when both are present and the elapsed is positive, and adds it as a new key in archive_data; notification_service.py:909-916 prefers actual_time_seconds and falls back to print_time_seconds only when timestamps weren't recorded (so the notification still has something if the elapsed can't be derived). (2) main.py:3172 adds "cancelled" to the set of statuses that get completed_at set when update_archive_status runs — pre-fix only completed, failed, aborted got a timestamp, but cancelled (Bambuddy queue UI cancellation, distinct from touchscreen-aborts which already set completed_at) was deliberately excluded for reasons that no longer hold. Audited every completed_at consumer in backend (archives.py:80, 333-337, 768-770, 723-731, 1722-1813, main.py:3229, projects.py:1475, 1489) and frontend (PrintersPage.tsx:2854, QueuePage.tsx:1053, StatsPage.tsx:902); none rely on completed_at IS NULL to mean "this is a cancelled print" — the three explicit-status filters already restrict to status == "completed" and the rest are completed_at or created_at fallback expressions that gracefully accept either. Knock-on benefit: the statistics-totals aggregation at archives.py:723-731 (which currently adds the full slicer estimate to the total when completed_at IS NULL) now adds the actual elapsed for cancelled prints too — a 2-minute cancellation contributes 2 minutes instead of 3 hours. Existing cancelled rows in the DB stay with completed_at=NULL; only new cancellations going forward get the timestamp. 3 new regression tests in test_notification_service.py::TestNotificationVariableFallbacks pin the contract: {{duration}} reflects actual_time_seconds when present (2m elapsed wins over 3h estimate), falls back to print_time_seconds when actual is missing (1h estimate still surfaced rather than "Unknown"), and surfaces "Unknown" when both are absent.
Frontend served behind a path-prefixed reverse proxy (e.g. /bambuddy/ on Traefik / nginx / Cloudflare Tunnel) loaded a blank page (#1195, reported by @Spegeli, follow-up to #1167) — Vite's default base: '/' emits absolute asset URLs in the built index.html (/assets/index-*.js, /assets/index-*.css, /manifest.json, /img/..., /sw-register.js), which assumes the SPA is always served at the host root. Behind any path-prefixed reverse proxy — Traefik with a path prefix, nginx location /bambuddy/, Cloudflare Tunnel with path routing, Synology / Unraid reverse-proxy panels — the browser then requests those absolute paths from the host root, the proxy doesn't see them, and the upstream serves either a 404 or HTML for an unknown path with Content-Type: text/plain/text/html; the browser logs Refused to apply style from '.../assets/index-*.css' because its MIME type is 'text/plain' and renders a blank white page. Two-line fix: frontend/vite.config.ts sets base: '' so Vite's HTML transform rewrites every absolute asset reference to relative (./assets/..., ./manifest.json, ./img/..., ./sw-register.js) — these resolve correctly against whatever subpath the document was served from. frontend/public/sw-register.js is a public-dir file Vite copies as-is, so its navigator.serviceWorker.register('/sw.js') call is changed to register('sw.js') (relative); the SW scope is automatically pinned to whatever subpath the document loaded from, which is exactly what every reverse-proxy-at-subpath user wants. Net effect: an https://example.com/bambuddy/ deployment now loads correctly without any frontend rebuild on the user's side. Out of scope for this change: runtime API base detection — API_BASE = '/api/v1' in frontend/src/api/client.ts is still absolute, so API calls still go to the host root. This is intentional. The fix above closes the immediate "blank page" report; making the API base, React Router basename, PWA manifest scope, and service-worker scope all subpath-aware would mean rewriting how the SPA bootstraps and would touch PWA-install state, push-notification subscriptions, and deep-link reload semantics. The supported way to embed Bambuddy in Home Assistant remains the Webpage panel + TRUSTED_FRAME_ORIGINS path documented in the wiki — Bambuddy reachable on a stable URL (HTTP for HTTP-only HA, HTTPS via your own reverse proxy for HTTPS HA / Nabu Casa / custom-domain), iframe-embedded via the HA dashboard. HA Ingress / addon-based subpath embedding (which would require the runtime path detection above) is not supported by core. Documented explicitly in docker.md so users hit the right pattern first.
iframe embedding from trusted origins (e.g. Home Assistant Webpage panel) no longer blocked (#1191, reported by @azurusnova) — Bambuddy ships strict anti-clickjacking headers (X-Frame-Options: SAMEORIGIN and CSP frame-ancestors 'none') by default, which protects internet-exposed deployments from being embedded by hostile sites. But it also broke a documented integration path: Home Assistant's Webpage dashboard panel embeds Bambuddy via <iframe> on a different origin (HA on :8123, Bambuddy on :8000), and the SAMEORIGIN value is port-strict, so even same-LAN trusted setups got "refused to connect". A new TRUSTED_FRAME_ORIGINS env var takes a comma-separated list of scheme://host[:port] origins; when set, the middleware drops X-Frame-Options (modern browsers honor frame-ancestors, and the legacy ALLOW-FROM <url> syntax is deprecated and inconsistent across vendors) and the CSP frame-ancestors directive becomes 'self' <origin> <origin>.... The default — empty env var — keeps the strict 'none' behavior, so Docker / bare-metal users without HA see no behavioural change. Origin validation happens at startup: only http:// and https:// are accepted, paths/query/fragments/wildcards are rejected with a warning (one bad entry doesn't take the deployment down — it's just dropped from the allowlist). The gcode-viewer route's frame-ancestors 'self' (same-origin embed for the in-app gcode preview iframe) also includes the allowlist when configured, so HA users embedding Bambuddy can still open the gcode viewer modal. 16 new tests in test_security_headers.py: 12 unit tests for the env-var parser (empty / unset / single / multiple / whitespace / empty-segment / non-http scheme dropped / missing host dropped / path dropped / query+fragment dropped / wildcard dropped / trailing-slash kept) and 4 integration tests for the middleware (default-strict emits SAMEORIGIN + 'none', allowlist relaxes CSP and drops X-Frame-Options, /docs branch also honors the allowlist, other security headers like X-Content-Type-Options and Referrer-Policy are unaffected in both modes). Documented in the Docker env-var reference page on the wiki and in .env.example.
Virtual Printer queue mode auto-dispatched onto the wrong colour when multiple compatible printers were available (#1188, reported by @EdwardChamberlain) — Sending a sliced 3MF to a queue-mode VP via Orca / Studio with auto-dispatch on caused Bambuddy to schedule the job onto a printer of the right model but the wrong loaded filament: a print sliced for matte white PLA would land on a printer with no white loaded, and the printer would start the job using whatever was the closest available match. Edward's diagnosis was exact (virtual_printer/manager.py:325-326): the manual /api/v1/print-queue/ POST flow extracts the 3MF's per-slot filament requirements at queue-add time and writes required_filament_types, filament_overrides, and ams_mapping on the resulting PrintQueueItem, so the scheduler's color-match enforcement (print_scheduler.py:512 — keys on filament_overrides[].force_color_match === true) actually runs. The VP queue-write path (_add_to_print_queue) skipped all of that and built a bare PrintQueueItem with only printer_id, target_model, archive_id, plate_id, position, status, manual_start. Net effect: the scheduler reached the model-only-matching fallback and accepted the first available printer of the target model regardless of loaded colour, exactly as he described. Fix: the scheduler's existing _get_filament_requirements 3MF parser is extracted into a shared helper (backend/app/services/filament_requirements.py:extract_filament_requirements) so the VP path can reuse it at upload time. The VP's _add_to_print_queue now calls that helper after archiving and populates required_filament_types unconditionally (cheap; helps the scheduler reject obvious type mismatches even without force_color_match); and writes filament_overrides with force_color_match: true per consumed slot when a new per-VP setting queue_force_color_match is on. Default is off to preserve current behaviour for upgraders — a fresh-install user who wants the bug-free behaviour flips the toggle once on the VP card; an existing user gets exactly the model-only-matching they had before until they opt in. Auto-dispatch onto the wrong material happens loudly enough that anyone affected can find the toggle. Why default-off rather than default-on: existing automation that relies on "send to queue VP, get printed somewhere" without caring about colour shouldn't silently start blocking on colour matching after an upgrade. The toggle has clear UI copy (virtualPrinter.queueForceColorMatch) explaining the trade-off. Defence in depth: a malformed or unparseable 3MF (e.g. fake bytes from a misconfigured upload tool) leaves both fields None and the scheduler falls back to model-only matching, matching pre-fix behaviour for the unhappy path. The scheduler itself is unchanged — it already handled force_color_match correctly when the field was populated; the bug was purely the VP path not populating it. Schema: one nullable column virtual_printers.queue_force_color_match BOOLEAN DEFAULT 0/FALSE (Postgres-safe) added via the existing _safe_execute migration pattern. API: VirtualPrinterCreate and VirtualPrinterUpdate Pydantic schemas + _vp_to_dict response shape carry queue_force_color_match, the create + update routes wire it through to the model, and VirtualPrinterInstance constructor + multiVirtualPrinterApi TypeScript client mirror the field. UI: new toggle on VirtualPrinterCard rendered only when mode === 'print_queue' (parallels the existing auto_dispatch toggle's mode-gating), with pendingAction state for the in-flight indicator. i18n: new virtualPrinter.queueForceColorMatch.{title,description} keys in all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copy pending native translation (matches the project's existing flow for newly-added user-facing features). 11 new tests: 8 in test_filament_requirements.py covering the extracted parser end-to-end (per-slot dicts, zero-use slots filtered, plate filtering, no-plate flat-walk fallback, unparseable / missing / config-less files, sorted output); 3 in test_virtual_printer.py::TestVirtualPrinterInstance covering the VP write path (setting-off → only required_filament_types populated; setting-on → filament_overrides populated with force_color_match: true per slot; unparseable 3MF → both fields None, no crash). Existing scheduler tests still pass against the refactored helper (verified end-to-end across the scheduler / virtual_printer / print_queue / filament test suites — 479 tests). Edward's "out of scope nice-to-have" suggestion of a "Requires Color Match" pill on queue cards is deferred to a follow-up so this PR stays scoped to his repro.
Slicing a library file via API key fails with "no Bambu Cloud session is stored" even when the key has cloud access (#1182 follow-up, reported by @turulix) — Tim shipped the headless slicing pipeline #1182 was filed for, then hit a second wall: GET /api/v1/cloud/settings returned the cloud preset IDs correctly (the /cloud/* router-level gate from #1182 was doing its job), but POST /api/v1/library/files/{id}/slice with those IDs in the request body failed the slice job with error_status: 400, error_detail: "Cloud preset selected for printer, but no Bambu Cloud session is stored. Sign in to Bambu Cloud and retry." Cause: the /cloud/* fix routes the API key's owner User through cloud_caller (a router-level gate stashes the owner on request.state.api_key_owner, route-level deps pull it back out), but the slice route lives on /library/* — different router, no gate, so when the auth dep returned None for the API-keyed request the slice route passed current_user_id=None straight through to _run_slicer_with_fallback → _resolve_cloud(db, user=None) → get_stored_token(db, None), which falls back to the auth-disabled global Settings table. That table is empty in auth-enabled deployments, so cloud preset resolution failed even though the key's owner User had a perfectly valid cloud_token on their User row. Fix is a new route-level dep resolve_api_key_cloud_owner in cloud.py that's permissive (returns the owner User if the key has can_access_cloud=true, otherwise None — never raises) so it can be safely added to non-/cloud/* routes without breaking the local-presets path: a request with an API key that lacks the cloud scope still slices fine against local presets, and only fails with the existing "no Bambu Cloud session" error if it actually selects a cloud preset. Wired into POST /library/files/{id}/slice (Tim's blocker) and GET /slicer/presets (the SliceModal preset dropdown source — same root cause, would have hit anyone using the UI through an API-keyed reverse proxy). Both routes now resolve the cloud-token owner via current_user or api_key_cloud_owner instead of current_user.id if current_user else None. The auth gate's None-return for API keys is unchanged — keeping that fix scoped to the routes that actually need cloud-token resolution prevents accidental scope creep into other routes that fence on current_user is None. 4 new integration tests in test_api_key_cloud_access.py::TestSliceRouteCloudOwnerResolution pin the dep contract: returns the owner for a key with can_access_cloud=True and a valid owner; returns None for an owned key without the cloud scope (so cloud presets still 400 cleanly, local presets still slice); returns None for legacy ownerless keys; no-op for JWT and anonymous callers.
Project cover photo thumbnail too small to recognise the print (#1155 follow-up, reported by @smandon) — The 40×40 thumbnail @smandon's MakerWorld download workflow relied on for "is this the model I'm looking for?" wasn't readable at that size; he asked for either a larger thumbnail or a click-to-enlarge full preview. Enlarging the thumbnail itself would shift the card layout and cost the dense grid he chose to use for browsing many projects, so the fix keeps the 40×40 thumbnail and shows a portal-mounted 384×384 popover on hover. The popover renders the full image in object-contain so tall portrait MakerWorld photos aren't cropped to a square, has pointer-events-none so it can't intercept hover and create a flicker loop, and z-[100] so it stacks above every sibling card in the grid. Why a portal: ProjectCard carries overflow-hidden (for its rounded-corner clipping and the color accent bar), so an in-tree popover gets clipped by the card the moment it extends past the card's bounds — exactly the cut-off behaviour @smandon reported on the second iteration. Rendering via createPortal(..., document.body) escapes every ancestor clipping context, and position: fixed with measurements from getBoundingClientRect() keeps the popover pinned next to the thumbnail regardless of where the card sits in the grid. Edge handling: if the thumbnail is near the viewport's right edge the popover flips to the LEFT side of the thumbnail; vertical position is clamped so the popover never overflows the window top or bottom. The thumbnail's own onClick is stopPropagation'd so hovering the popover area never accidentally triggers the parent card's "open project" navigation. 2 new tests in ProjectsPage.test.tsx pin the contract: hovering mounts the popover at document.body level (not nested in the card — a future refactor that drops the portal would re-introduce the clipping bug, and the test catches that); leaving unmounts it; the popover img points at the same cover-image URL as the small thumbnail with object-contain; cards without a cover_image_filename never mount the portal-rendering component (so a hover doesn't flash an empty preview).
Spool edit form lost the Extra Colours value on reopen, Dual Color rendered identically to Gradient, and the Sparkle / checkerboard visuals were too subtle (#1154 follow-up, reported by @maugsburger) — Four issues against the multi-colour swatch work that landed for #1154. (1) Extra Colours input didn't hydrate on edit reopen: ColorSection's draft buffer was seeded once via useState(formData.extra_colors), but SpoolFormModal opens before its own useEffect populates formData from the spool record — so by the time the saved value landed, the input's local state had already been initialised to '' and never re-synced. The COLOR preview banner above the input rendered correctly (consumes formData directly), making it obvious the data WAS persisted; only the input was stuck blank, which the user then had to retype to save anything else. Fix: a ref-guarded useEffect resyncs extraColorsDraft when formData.extra_colors changes via an external update (e.g. modal opening with a spool); the ref is updated inside commitExtraColors so the user's own typing is round-tripped without the resync clobbering it. (2) Dual Color and Gradient produced the same diagonal blend: buildColorLayer in filamentSwatchHelpers.ts ran the same linear-gradient(135deg, ...) for both effect types, so a "Dual Color" spool was visually indistinguishable from a "Gradient" one. Real dual-colour spools have two distinct bars on the reel — that's the whole point of the variant. Fix: when effect_type is dual-color or tri-color, build the colour layer as linear-gradient(to right, c1 0% X%, c2 X% Y%, ...) with CSS double-position stops (so the colour change is a hard line rather than a blend region) and equal-width segments across the stops; gradient keeps the original 135° smooth blend. The existing multicolor conic-gradient path is untouched. (3) Sparkle effect was almost invisible on card-sized swatches: the original 4-dot pattern (each ~1px) read fine on the small inline swatch but disappeared on the 60-pixel-tall inventory card banners — exactly where the user actually identifies a spool. Bumped to 13 flecks in mixed sizes (1px / 1.5px / 2px) and varying opacity (0.65 → 1.0) to give a depth-of-field "metal flake" feeling, distinct from solid + multi-colour. (4) Checkerboard cell density scaled with the swatch: the previous helper put repeating-conic-gradient(...) in the background-image and the caller applied background-size: cover, so the same 4-cell pattern was either tiny squares on a small swatch or four huge squares on a card-sized banner. Made buildFilamentBackground() return { backgroundImage, backgroundSize } with per-layer sizes — painted layers stay cover, the checkerboard gets a fixed 12px tile so the cell density stays consistent regardless of element size and clearly reads as a transparency indicator rather than a multi-colour stripe. Updated the three existing call sites (InventoryPage group banner + spool card, ColorSection preview) to spread the returned style object directly. 8 new frontend tests cover the four fixes: hard-split contract for Dual/Tri Color (3 tests + 1 regression guard that Dual ≠ Gradient for the same stops); Sparkle prominence (≥ 10 distinct radial-gradient layers in the rendered background); checkerboard density (last backgroundSize layer is a fixed pixel value, not cover); 4 hydration tests pinning the input restore path (fills when formData arrives via parent update, resyncs when the spool changes mid-form, doesn't clobber live user typing, clears when the new spool has no extra_colors).
Pending review card and the resulting archive name disagreed; .gcode.3mf filename suffix wasn't fully stripped (#1152 follow-up, reported by @smandon) — Two distinct holes in the original #1152 fix surfaced when @smandon retested on the daily build. (1) Suffix stripping was incomplete: Bambu Studio's "Send to printer" dialog typically writes files like Plate_1.gcode.3mf (a sliced gcode payload wrapped in a 3MF container), but the archive's display stem was computed via Path(name).stem, which only drops the last suffix and left the user staring at Plate_1.gcode in the archive UI. (2) The review card and the archive disagreed on what the print was called: the pending-uploads panel always rendered the raw FTP filename, while the eventual PrintArchive.print_name resolved from the 3MF's embedded title (or, with the toggle on filename, the filename stem). Net effect: the user saw Plate_1.gcode in the review card and Some Creator's Title in the archive grid for the same item, with no toggle that flipped both views in lockstep. Fix has three pieces: a new resolve_display_stem() helper in archive.py that strips .gcode.3mf / .3mf / .gcode (case-insensitive) so both the archive and the review-side normalisation produce the same canonical stem; a new PendingUpload.metadata_print_name column populated at FTP-receive time by peeking at the 3MF's embedded title (so /pending-uploads/ list calls don't have to reopen every 3MF on every render); and a new PendingUploadResponse.display_name computed field that mirrors archive_print's exact precedence — filename toggle: stripped stem; metadata toggle (default): cached title or stripped stem. Frontend's PendingUploadsPanel reads upload.display_name (with upload.filename as a defensive fallback for any pre-migration row), and the raw filename is exposed as a tooltip so users can still inspect what actually arrived over FTP. Migration is one idempotent ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255) (Postgres/SQLite-safe); existing pending rows have NULL there and gracefully fall back to filename-stem behaviour. 14 unit tests pin the stripping rules (Plate_1.gcode.3mf → Plate_1, mixed case, dots in the middle, edge .3mf-only / .gcode-only, full-path inputs); 6 integration tests pin the response contract (default toggle uses metadata title when present, falls back to stripped stem when absent, filename toggle overrides metadata, filename toggle still strips the double suffix, GET /{id} exposes the same field, whitespace-only metadata behaves like absent); 3 frontend tests pin the review card's render path (resolved name shown, fallback to filename when display_name is empty, raw filename available via tooltip).
SpoolBuddy SSH update fails with "permission denied for user spoolbuddy" after Bambuddy keypair rotation (reported during user testing) — Bambuddy's data dir at <DATA_DIR>/spoolbuddy/ssh/ can get recreated outside the daemon's control (volume remount, container recreate, fresh deploy), at which point get_or_create_keypair() generates a new ed25519 keypair. The SpoolBuddy daemon previously only fetched and deployed Bambuddy's public key at registration time (/devices/register), so any rotation after a successful registration left the device's ~/.ssh/authorized_keys pointing at a defunct public half — every "Update" click from the Bambuddy UI then failed with Connection closed by authenticating user spoolbuddy [preauth] until the daemon was restarted manually. Worse, every prior successful registration appended a fresh entry to authorized_keys without ever pruning the old one, so a typical device accumulated 5+ stale Bambuddy-tagged keys (each one a permanent backdoor for whichever Bambuddy keypair held the matching private half at the time it was deployed). Two-pronged fix: (1) the heartbeat response (HeartbeatResponse, routes/spoolbuddy.py:282) now carries the current ssh_public_key alongside the existing pending_command / calibration fields, so the daemon's heartbeat picks up a key rotation within one cycle instead of needing a service restart; the same try/except Exception: pass pattern as the registration response keeps a missing/unreadable backend key from breaking telemetry. (2) _deploy_ssh_key() in daemon/main.py now syncs rather than appends — it strips every line tagged bambuddy-spoolbuddy, writes the current key once, and is a no-op when already in sync (so it doesn't churn the file every heartbeat). User-managed entries (any line not tagged bambuddy-spoolbuddy) are preserved untouched. 5 new unit tests in spoolbuddy/tests/test_deploy_ssh_key.py (creates-when-missing → mode-600 file with the current key; pile-up-of-stale-keys → only current key remains, no growth; preserves-unrelated-user-keys → user's own SSH access untouched; idempotent-when-in-sync → no mtime change so heartbeat doesn't churn the file; swallows-write-errors → readonly-fs PermissionError doesn't crash the heartbeat loop). 2 new backend integration tests in test_spoolbuddy.py::TestDeviceEndpoints — test_heartbeat_returns_ssh_public_key (response carries the key on every heartbeat) and test_heartbeat_ssh_key_failure_does_not_break_heartbeat (backend key-read failure leaves ssh_public_key: None but the heartbeat still 200s).
External-camera frames returned as black on go2rtc and other MJPEG sources (#1177, reported by @nkm8) — _capture_mjpeg_frame returned the very first JPEG it found in the stream's bytes (backend/app/services/external_camera.py:282), but many MJPEG sources — go2rtc most notably, and several IP cameras — emit a "warm-up" frame on the byte that follows connection accept: usually the last keyframe held in the encoder, which is often black or stale until the encoder catches up to live content. Subsequent frames on the same connection are fine. The reporter saw it across snapshot UX, finish photos in notifications, and timelapse — every code path that opens a fresh capture connection (snapshot endpoint, [PHOTO-BG] finish photo, plate-detection CV, Obico ML inference, layer timelapse, Settings → Test). His own observation that go2rtc's /api/frame.jpeg (single-frame, internally already warmed) is never black while the first frame off /api/stream.mjpeg is, matched the hypothesis exactly. Support-bundle evidence was clean: every black notification frame in his log was 11095 bytes (a pure-black 1280×720 JPEG encodes to ~10–15 KB on standard libjpeg quality settings), while every captured-after-warm-up frame from the same source was 30–45 KB. Fix: read past the first frame and return the second; if the connection closes / times out / hits the 5 MB buffer cap before a second frame ever arrives, fall back to the first so callers still get something (degrading slow / single-frame streams to None would regress every code path that relied on pre-fix behaviour). The inner-loop now drains every complete frame already in the buffer before pulling the next chunk so high-FPS sources that pack multiple frames per chunk are handled correctly. The snapshot / rtsp / usb capture paths and the live-view streaming endpoint (generate_mjpeg_stream) are untouched. 7 new regression tests in test_external_camera.py::TestCaptureMjpegFrameWarmupSkip cover (a) two-frames-in-two-chunks → second returned, (b) two-frames-in-one-chunk → second returned, (c) frame split across chunk boundary → assembled correctly, (d) single-frame stream → first returned via fallback (no None regression), (e) timeout after first frame → first returned via fallback, (f) zero-frame stream → None, (g) non-200 status → None. Latency penalty: at most one frame interval (typically 50 ms – 1 s on a steady stream). Follow-up: optional snapshot URL override — @nkm8 retested on the daily build and saw the warm-up skip help most of the time but the black-frame symptom still surfaced intermittently on his go2rtc setup, with the same workflow break (notification thumbnails black, snapshot UX black). His own bisect already pointed at the cleanest fix: go2rtc exposes /api/frame.jpeg as a dedicated single-frame endpoint that never returns the encoder's warm-up keyframe, while /api/stream.mjpeg always does on a fresh connection. New optional external_camera_snapshot_url column on printers (idempotent ALTER TABLE migration via _safe_execute, plumbed through PrinterBase / PrinterUpdate / PrinterResponse / from_orm_with_roi / TypeScript Printer + PrinterCreate); when set, every single-frame capture path (/api/v1/printers/{id}/camera/snapshot, [SNAPSHOT] notification thumbnails, [PHOTO-BG] finish photo, layer timelapse on every captured layer, Obico ML snapshot, plate-detect / calibrate-plate CV) routes through _capture_snapshot() on the override URL via plain HTTP GET, bypassing the warm-up-frame dance entirely. The override is camera-type-agnostic — set it once on the printer config and it applies regardless of whether the live stream is mjpeg / rtsp / usb. Live-view (the /camera/stream and /camera endpoints powering the in-app viewer) deliberately stays on the configured stream URL — the override only changes single-frame captures, since a 1 fps poll-the-snapshot-endpoint live view would be a regression for everyone who doesn't have this problem. Settings UI (Settings → General → External Cameras) renders a new "Snapshot URL (optional)" input with its own Test button below the live-stream URL row; the input is hidden when camera_type === 'snapshot' since the live URL is already a single-frame endpoint and the override would be redundant. SSRF guard on the override is the existing _sanitize_camera_url("http", "https") allowlist — link-local / metadata / blocked hosts return None instead of being fetched. Empty-string override is treated as unset (defence in depth — a stale config row that somehow has "" rather than NULL still routes through the live stream rather than firing GET against an empty URL). 5 new backend tests in test_external_camera.py::TestSnapshotUrlOverride (override routes to snapshot path; no override → camera-type handler; empty string → camera-type handler; SSRF guard on metadata-target override returns None; override is camera-type-agnostic across rtsp/usb). 3 new frontend tests in SettingsPage.test.tsx (input renders for mjpeg/rtsp/usb camera types; hidden for snapshot type; debounced PATCH carries external_camera_snapshot_url when the user types). i18n: settings.cameraSnapshotUrl{,Placeholder,Help} in en + de fully translated, the other 6 locales (fr/it/ja/pt-BR/zh-CN/zh-TW) seeded with English copies pending native translation. Documented under bambuddy-wiki/docs/features/camera.md with the go2rtc example URL as a tip block.
MakerWorld sidebar entry visible to every user regardless of group permissions (#1175) — Backend already enforced makerworld:view on every /makerworld/* route (backend/app/api/routes/makerworld.py:145, 157, 242, 406), the permission was correctly granted to the admin and standard-user role defaults (permissions.py:298, 364, 454), and the frontend Permission type union already included 'makerworld:view' | 'makerworld:import' (client.ts:2498) — but the sidebar's hand-maintained navPermissions map in Layout.tsx:278 had no entry for makerworld, so isHidden('makerworld') always returned false and the entry rendered for every authenticated user. Users without the permission saw the entry, clicked, and the page rendered while every API call inside it 403'd. Two-line fix: (1) Layout.tsx:278 — add makerworld: 'makerworld:view' to the map, matching every other sidebar entry's gating shape; (2) App.tsx:200 — wrap the route in <PermissionRoute permission="makerworld:view"> for defence in depth, so a user who knows the URL can no longer reach the page directly (matches the existing pattern on settings, groups/new, groups/:id/edit two lines below). 2 new Layout tests pin the contract: with auth enabled and a user lacking makerworld:view, the sidebar <a href="/makerworld"> link is absent (other links like /files still render); with the permission granted, the link renders.
Printer Info modal: serial-number and IP-address copy buttons silently did nothing on plain-HTTP LAN deployments (#1174, reported by @BurntOutHylian) — PrinterInfoModal's CopyButton only tried navigator.clipboard.writeText(), which is gated by the secure-context requirement (HTTPS or localhost). On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN — navigator.clipboard is undefined; the existing try/catch swallowed the resulting TypeError, the icon never flipped to the tick, and nothing landed on the user's clipboard. Fixed by adding the same off-screen-textarea + document.execCommand('copy') fallback that CameraTokensPage's plaintext-token modal already uses for plain-HTTP LAN deployments: gate on navigator.clipboard && window.isSecureContext, fall back to the legacy path otherwise, and surface the success-tick only when the copy actually landed (return early without flipping copied if execCommand('copy') returns false). The try/finally around the textarea guarantees DOM cleanup even when the browser throws on a restricted context. 3 new component tests in PrinterInfoModal.test.tsx cover (a) secure-context happy path uses navigator.clipboard.writeText, (b) plain-HTTP fallback path actually invokes execCommand('copy') and leaves no leaked textarea in the DOM, (c) finally cleanup removes the textarea even when execCommand throws synthetically. Thanks to @BurntOutHylian for the precise file/line pointer in the report.
Queue auto-dispatched the next print onto a fouled bed after an aborted or cancelled print (#1171, reported by @tom5677) — When a print ended with status aborted (printer self-abort, or a user stopping the print on the printer's own touchscreen) or cancelled (user stopping the print via the Bambuddy queue UI), the plate-clear gate added in #961 was not raised — only completed and failed triggered it (backend/app/main.py:2660). Result: the queue scheduler dispatched the next pending item ~2 seconds after the abort, with the previous print's material still on the bed. The reporter saw two prints (P1P + P1S) auto-start onto fouled beds within seconds of each other after touchscreen-aborts, and explicitly flagged the risk of damage to the printer; a third printer (his second P1S) behaved correctly because its previous print had ended completed. The original code's comment ("user-cancelled prints don't require a plate-clear ack — nothing printed on the bed") only holds if you cancel right at layer 1; cancelling a 12-hour print at hour 11 leaves a fouled bed too. Fix: the gate is now raised for every terminal status — completed, failed, aborted, cancelled — matching the safety contract that the user must acknowledge the bed is clear before any next queued print starts. The gate is user-clearable on the Printers page, so worst case for a layer-1 cancel the user clicks "Clear Plate" once. Touchscreen-aborts are particularly important to gate because Bambuddy's "user stopped via UI" override (_user_stopped_printers → aborted mapped to cancelled) only fires when the user stops via the Bambuddy queue; a touchscreen-stop reports aborted straight through. Regression coverage in test_print_lifecycle.py::TestPlateClearGate: parametrised across all four terminal statuses (asserts set_awaiting_plate_clear(printer_id, True) is called for each), plus a defence-in-depth test that an unrecognised future status string never silently raises the gate.
Printer card always shows the first plate's thumbnail when printing a multi-plate 3MF (#1166, reported by @smandon) — On printers running firmware that drops the plate path from print.gcode_file (the reporter's case: P1S 01.10.00.00, but the same shape appears on other firmware revisions), the printer reports gcode_file: MyModel.3mf instead of gcode_file: /Metadata/plate_4.gcode. The /printers/{id}/cover route's regex (plate_(\d+)\.gcode) found nothing in the bare .3mf filename, defaulted to plate 1, and the printer card showed Metadata/plate_1.png from the 3MF — even though the user dispatched plate 4. Same problem hit current_plate_id on the status response (printer card detail row showed plate 1). Two-pronged fix on a precedence ladder: (1) Bambuddy now records the plate it dispatched — start_print() writes (dispatched_plate_id, dispatched_subtask) onto PrinterState at publish time, and a new resolve_plate_id(state) helper prefers that record over the gcode_file regex when dispatched_subtask == state.subtask_name (the subtask check rejects stale entries from a prior Bambuddy-dispatched print bleeding into a Studio-direct dispatch). (2) After the 3MF lands on disk, the cover route scans the zip for a unique Metadata/plate_*.gcode entry: per-plate archives sliced separately in Bambu Studio bundle thumbnails for every plate but only the active plate's gcode, so a single match unambiguously identifies the plate even when no Bambuddy dispatch exists (Studio-direct flow). Final fallback is plate 1, unchanged. The cover-byte cache key was also simplified — plate_num was removed from the key now that resolution is late-bound; clear_cover_cache() already runs on every print start, so different plates of the same project always re-fetch a fresh thumbnail. Coverage: 5 unit tests in test_printer_manager.py::TestResolvePlateId (dispatch precedence, stale-subtask guard, gcode regex fallback, default-1 path, missing-subtask guard), 4 unit tests in test_bambu_mqtt.py::TestStartPrintRecordsDispatchedPlate (dispatch record set/cleared/overwritten/skipped on disconnect), 2 integration tests in test_printers_api.py (dispatch wins over plate-1 default; 3MF-scan fallback for per-plate archive without dispatch). Studio-direct multi-plate prints (no dispatch record AND multiple plate gcodes in the 3MF) still default to plate 1 — matches the firmware's own ambiguity, not regressed by this change.
AMS slot configuration intermittently fails to reach the printer after several configs in a row (#1164, reported by @RosdasHH) — Configuring AMS slots a handful of times (the reporter saw it almost every 6th change) would silently stop reaching the printer; ~1 minute later the filament colours on the printer would briefly jump between slots, then settle. Root cause was the zombie-session watchdog at bambu_mqtt.py:861 introduced for #887. When an ams_filament_setting response took >10 s (normal under load — concurrent K-profile fetches, busy printer, network jitter) the watchdog incremented an _ams_cmd_unanswered counter and zeroed _last_ams_cmd_time so it wouldn't re-trigger on the next status push. The bug: the response handler that reset the counter was guarded by and self._last_ams_cmd_time > 0 — so when the late response did arrive (after the watchdog had already zeroed the timer), the counter stayed armed at 1. The next slow response on any ams_filament_setting command — possibly minutes or hours later, on an entirely unrelated config attempt — would take the counter to 2 and trigger force_reconnect_stale_session(). The user-visible symptoms match exactly: configs stop landing (because MQTT reconnects mid-publish, dropping the in-flight command and surfacing as Cannot set AMS filament setting: not connected if the user retries during the ~1 min reconnect window), then the queued state finally lands when the reconnect completes (the "filament colours jumping around" the reporter described). Fix is to drop the _last_ams_cmd_time > 0 guard: any ams_filament_setting response — late or not — proves the channel is alive, so the counter must reset. Watchdog still trips on a real zombie session (no responses at all for two consecutive >10 s windows). Regression test in test_bambu_mqtt.py::TestZombieSessionDetection::test_late_response_after_watchdog_clears_counter_issue_1164 simulates the exact sequence (watchdog fires → late response arrives → second slow response on a fresh command) and asserts the counter resets to 0 on the late response and the second command doesn't tip the threshold to 2. Other 10 zombie-detection tests still pass unchanged. Follow-up: cumulative session wedge after ~16-20 commands — the watchdog fix above heals real zombie sessions, but @RosdasHH continued to see the wedge fire on healthy sessions after enough cumulative commands (configs + spool assignments share the same threshold: "8 + 3", "12 + 1", "16 + 0" all tripped it). His QoS=1 vs QoS=0 vs QoS=2 bisect was the breakthrough — the wedge only happens at QoS=1. paho-mqtt's default max_inflight_messages is 20, and Bambu's broker has racy PUBACK matching that leaves some inflight slots unreleased per session, so after ~16-20 cumulative commands the queue silently fills and publish() returns success while packets sit in paho's internal queue (force_reconnect heals it because the inflight queue is per-session — the printer had already processed every command, it just couldn't receive any new ones until the session reset). Lifted the ceiling to 1000 via client.max_inflight_messages_set(1000) immediately after mqtt.Client() construction (bambu_mqtt.py:3074-3079). Keeps QoS=1 untouched (the cross-model reliability we deliberately chose for AMS configuration — A1, P1S, X1C, H2D, P2S, X2D all need it) and removes the ceiling as the bottleneck without changing wire-protocol behaviour. The watchdog reconnect from the original fix above stays as defence-in-depth for sessions that go truly zombie. Diagnosis credit: @RosdasHH's careful bisect.
EC984C,#6CD4BC,A66EB9,D87694 — exact format from 3dfilamentprofiles.com) and renders them as a CSS gradient on every swatch site (inventory grid, table, group banner, card, ColorSection preview, color-catalog admin). A new Effect dropdown — covering surface effects (Sparkle / Wood / Marble / Glow / Matte), sheen variants (Silk / Galaxy / Rainbow / Metal / Translucent), and structural variants (Gradient / Dual Color / Tri Color / Multicolor) — layers a CSS overlay on top of the colour layer (or, for Multicolor, switches the colour layer to a conic-gradient even when no spool subtype is set, so the catalog editor can flag a multicolor variant directly without needing a paired Spool row). Independent of subtype — so the user can override the visual hint without touching Bambu's categorical filament label or the MQTT auto-detection chain. Transparency is now actually visible: the existing rgba column has always stored an alpha byte but every render site flattened it with substring(0, 6); the new shared <FilamentSwatch> component renders against a checkerboard layer beneath the colour layer so any alpha < 0xFF shows through (matches the convention used by image editors and 3dfilamentprofiles.com). Multicolor subtype swaps the linear gradient for a conic-gradient so the swatch reads as a colour wheel pie instead of a stripe — visually distinguishes a true multi-colour spool from a 2-stop gradient. Colour-catalog parity: the same fields land on ColorCatalogEntry (Settings → Color Catalog) so a user can save a multi-colour combo once and pick it from the catalog palette across spools — added inline to both the Add form and the inline-edit row, threaded through the JSON export/import path so catalog backups round-trip the new fields. Catalog hex_color regex extended to optionally accept #RRGGBBAA for transparency-aware catalog entries (backward-compatible — existing 6-char rows still validate). Schema validation (backend/app/schemas/spool.py::normalize_extra_colors + normalize_effect_type — public so ColorEntryCreate / ColorEntryUpdate can reuse them): comma-separated hex with 6-or-8-char tokens, lowercase canonical form, # prefix stripped, max-8-stop cap, empty tokens dropped (so a degenerate paste like ,,FF0000, survives), invalid tokens rejected at the Pydantic layer with a precise field error. Effect type validated against the fixed set {sparkle, wood, marble, glow, matte, silk, galaxy, rainbow, metal, translucent, gradient, dual-color, tri-color, multicolor} — paste-friendly normaliser tolerates Dual Color / dual_color / dual-color and canonicalises to dual-color. Both validators live next to SpoolBase and are reused by ColorEntryCreate / ColorEntryUpdate so spool-side and catalog-side rejection rules can never drift. Frontend swatch component is one shared <FilamentSwatch> (and buildFilamentBackground() helper for callers that want just the CSS background-image string for a banner) — used by InventoryPage table, group banner, SpoolCard, ColorSection preview, and ColorCatalogSettings — so there's exactly one place that decides how a filament looks. Colour layer is built as a list of CSS images (no background: shorthand) so jsdom and every browser parse it consistently; checkerboard layer is the one that makes alpha visible. Migrations are 4 idempotent ALTER TABLE ... ADD COLUMN (Postgres-safe, no DEFAULT 0 traps) plus a Postgres-only widen of color_catalog.hex_color to VARCHAR(9). i18n: 12 new keys under inventory.* across all 8 locales (en/de/zh-CN/zh-TW fully translated; fr/it/ja/pt-BR seeded with English copies pending native translation, matching the project's flow for newly-added user-facing features). 42 new backend tests (35 unit + 7 integration) covering the normalizer (paste-from-3dfilamentprofiles canonicalisation, whitespace tolerance, mixed 6/8-char, empty-token drop, max-stop cap, invalid-hex rejection, wrong-length rejection, Dual Color / dual_color → dual-color canonicalisation), effect-type validator across all 14 allowed values, end-to-end POST/PUT/PATCH round-trip on both spool + catalog routes, 8-char hex_color acceptance, dedupe-on-update, and field clearing via empty string vs explicit null. 20 new frontend tests covering FilamentSwatch (14 — solid render, multi-stop linear gradient, conic for Multicolor subtype AND for multicolor effect_type via the catalog path, surface-effect overlays for sparkle and silk, categorical-only-no-overlay for gradient/dual-color, unknown-effect-ignored, checkerboard rendering for alpha, invalid-hex skip in stops, title fallback), buildFilamentBackground helper, ColorCatalogSettings (3 — Add form sends extra_colors + effect_type, full 14-value dropdown, inline edit hydrates from existing entry), and InventoryPage spool grouping (3 — different extra_colors don't collapse, different effect_type don't collapse, identical multi-colour spools still group). Out of scope for V1: gradient stop positions (e.g. 25%/75%), MQTT-side auto-import of multi-colour from Bambu (firmware doesn't expose this), per-effect tunable parameters — the current shape closes the user's actual paste-from-3dfilamentprofiles workflow without taking on a structured-stop-position editor.e.stopPropagation()-guarded so it doesn't enter the project) and a cover photo (replaces the status-icon box on the card with a square thumbnail). The URL field is plumbed through ProjectCreate/ProjectUpdate/ProjectResponse/ProjectListResponse, including from-template + create-template flows so the URL inherits between a project and its template; cover photo is not inherited because the file would be shared on disk between the source and copy. Schema validator rejects anything other than http:// or https:// prefixes — <a href> rendering would otherwise execute javascript: / data: / file: URLs even with React's default escaping. Cover image storage: Project.cover_image_filename references a file inside the existing archives/projects/{id}/attachments/ directory, but it's tracked as a separate column from the attachments JSON list so swap/delete operations on the cover don't perturb the user's other attachments. Three new routes (POST /projects/{id}/cover-image, GET /projects/{id}/cover-image, DELETE /projects/{id}/cover-image) — accepts only .jpg/.jpeg/.png/.gif/.webp (no SVG: SVG can carry script payloads), replaces in place (the prior file is removed from disk before the new one lands so repeat uploads can't accumulate orphans), and self-heals when a DB reference points at a vanished disk file by clearing the column and 404'ing rather than repeatedly touching the filesystem. GET auth gate: the cover-image GET route is gated by RequireCameraStreamTokenIfAuthEnabled (accepts the same ?token=… stream credential the archive thumbnail route uses) rather than the bearer-token gate — <img src> requests can't carry an Authorization header, and the bearer gate would silently 401 every cover image when auth is enabled. The frontend client wraps the URL with withStreamToken(...) so the modal preview AND the card thumbnail load in both auth-on and auth-off configurations. PATCH update uses model_fields_set for the URL field so users can clear it by sending {"url": null}. Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ for the GET (via the stream-token gate). Migration: 2 idempotent ALTER TABLE projects ADD COLUMN ... statements. Localised across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) — English fully translated, the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features. 7 backend integration tests covering URL accept/reject (https/javascript/data), URL clear, cover image upload→serve→delete round-trip with content-type assertion, non-image rejection, and a regression guard verifying the GET route is wired to the stream-token gate (not the bearer gate). 4 frontend ProjectsPage tests covering the link icon render condition, click-propagation guard, no-link-when-unset, and cover-image thumbnail render; 3 frontend client tests pinning that getProjectCoverImageUrl appends the stream token, returns the bare URL when no token is set, and URL-encodes tokens with query-string-unsafe characters. Cover image upload is only available on the edit modal (an existing project), since the upload needs a project_id; new projects can add it after first save.status='archived' (uploaded but never sent to a printer), but the existing Collection sidebar only had All / Recent / This Week / This Month / Favorites / Failed / Duplicates so there was no way to surface "what's still queued in my library that I haven't printed yet" vs "what already went to a printer." Two new collections fill that gap: Not Printed filters to status === 'archived' (the VP upload state); Printed filters to any final-status archive — completed, failed, aborted, cancelled, stopped — so a user can see every archive that had a print attempt regardless of outcome (the existing "Failed" collection covers just the failure subset). Frontend-only — the data has always been there, just no UI handle for it. 2 new tests in ArchivesPage.test.tsx::Not Printed / Printed collections pin the filter behaviour against a fixture covering all 4 status states (archived / completed / failed / cancelled).print_name metadata, which is whatever the original creator set; users who renamed a job in BambuStudio's "Send to printer" dialog never saw that name surface in Bambuddy because the FTP-uploaded filename was only ever used as a fallback when the metadata was empty. Settings → Virtual Printer now exposes an Archive name source toggle (Metadata / Filename, default Metadata, preserves existing behaviour) at the top of the page that flips precedence in ArchiveService.archive_print for every VP-sourced archive — _archive_file, _add_to_print_queue, POST /pending-uploads/archive-all, and POST /pending-uploads/{id}/archive all read the new virtual_printer_archive_name_source setting and forward prefer_filename_for_name accordingly. Backend validates the value to metadata/filename only. Strict locales (en/de/zh-CN/zh-TW) get full translations; 4 unit tests parametrised over filename / metadata / unset / empty-string pin the precedence rule end-to-end through _archive_file. Existing post-archive PATCH /archives/{id} rename path is unchanged.Multi-color slicing in the Slice modal, with per-plate filament discovery for unsliced project files — Initial slice support assumed a single filament profile per slice; multi-color 3MFs were silently truncated to the first slot, producing wrong colours on every non-trivial print. The Slice modal now (1) opens a plate-picker step first when the source is a multi-plate 3MF, (2) renders one filament dropdown per AMS slot the picked plate actually uses, with each dropdown auto-populated against the user's local + standard presets by (filament_type, filament_colour) match, and (3) submits the user's picks as an ordered filament_presets: PresetRef[] array which is forwarded as repeated filamentProfile multipart parts to the slicer sidecar (the CLI joins them with ; for --load-filaments). Per-plate filament list source-of-truth chain: for a sliced archive the modal reads Metadata/slice_info.config directly (existing path); for an unsliced project file (where slice_info.config is empty until Bambu Studio actually slices), the new slice_preview service runs a fast preview-slice via the sidecar's slice_without_profiles (the project's embedded settings drive the slice; we throw away the gcode and only parse the resulting slice_info), and the result is cached by (kind, source_id, plate_id, content_hash) with LRU eviction at 256 entries — repeat opens of the same plate are instant. If the sidecar isn't reachable the modal falls back to a heuristic that reads Metadata/project_settings.config for the AMS slot config and intersects it with the plate's painted-face data (paint_color quadtree leaves on per-object .model files, scanned with a 5% noise threshold to drop single-leaf edit accidents). SliceModal-only tier priority is now local → cloud → standard (was cloud → local → standard): imported profiles win because they carry parsed type/colour metadata in the response, while cloud entries don't (the per-preset detail endpoint rate-limits at ~10/sec per token and 50+ parallel fetches returned 429 on every request). The unified-listing endpoint's dedup pass now backfills metadata cross-tier — if a cloud entry wins dedup over a same-named local entry, the cloud entry inherits the local's filament_type / filament_colour so the Slice modal's metadata-aware pre-pick keeps working for users who have presets both cloud-synced and locally imported. Other consumers of /slicer/presets (Profiles page, etc.) retain the existing cloud-first dedup. Sidecar (orca-slicer-api fork, bambuddy/profile-resolver branch): /slice now accepts up to 16 repeated filamentProfile parts (was hard-capped at 1), the slicing service materializes each as filament_N.json and joins paths into a single --load-filaments "a.json;b.json;c.json" invocation; /profiles/bundled listing was extended with filament_type and filament_colour per leaf so the bundled tier carries metadata into the modal. Sliced-archive card now reflects the actually-used filament list, not the project-wide AMS config: slice_and_persist_as_archive previously copied filament_type and filament_color from the unsliced source archive verbatim, which inherited every project-wide AMS slot (16+ swatches on the card for a 2-color print). The new archive now reads those fields from the sliced output's slice_info.config via ThreeMFParser (which already gates on used_g > 0), falling back to the source archive's values only if parsing failed. Backwards compatibility: SliceRequest schema accepts three shapes — legacy filament_preset_id: int, source-aware singular filament_preset: PresetRef, multi-color array filament_presets: list[PresetRef] — the validator promotes any of them into a populated filament_presets list before the route handler runs, and stale browser tabs from before this change keep working unchanged. Permissions: no new endpoint paths added; the preview-slice runs inside /filament-requirements (gated on LIBRARY_READ / ARCHIVES_READ) and the multi-filament dispatch runs inside POST /slice (gated on LIBRARY_UPLOAD) — no auth surface widened. Tests: 6 schema tests for SliceRequest covering the multi-filament list shape and legacy-vs-new precedence; 9 unit tests for slice_preview covering happy path, content-hash invalidation, sidecar-failure no-cache-poison, concurrent-call thundering-herd guard via per-key asyncio.Lock, and LRU eviction-with-lock-cleanup; 15 unit tests for extract_project_filaments_from_3mf (5 cases) and extract_plate_extruder_set_from_3mf (10 cases including the 60/40 painted-threshold pin); a multi-filament wire-format test on slice_with_profiles pinning that N filament profiles produce N repeated multipart parts in submission order; 22 frontend SliceModal tests covering the plate picker step, multi-color rendering, metadata-aware pre-pick, manual slot override, archive-vs-library routing, and the new tier order. Localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation per the project's existing flow).
Slicer presets now span Cloud, imported, and slicer-bundled tiers, end-to-end — Initial slicer integration only saw DB-backed local imports, so a user without imported profiles got an empty Slice modal even when their Bambu Cloud account or the slicer sidecar carried perfectly usable presets. The Slice modal now pulls from three tiers in priority order — cloud (the user's own Bambu Cloud presets), local (DB-backed imports), standard (slicer-bundled stock profiles) — with name-based dedup so a preset that exists in multiple tiers only renders in the highest-priority one (cloud > local > standard) and within-tier order is preserved exactly. Listing (GET /api/v1/slicer/presets): cloud branch is per-user with a 5-minute cache keyed on (user_id, sha256(token)[:16]) so a logout/login or token rotation auto-invalidates without callback wiring from the cloud-auth routes. Bundled branch is global with a 1-hour cache (sidecar's read-only filesystem only changes across image rebuilds). cloud_status (ok / not_authenticated / expired / unreachable) drives a precise modal banner instead of an unexplained empty list. Slicing (POST /library/files/{id}/slice, POST /archives/{id}/slice): request body now accepts source-aware {source, id} triplets per slot (cloud / local / standard) alongside the legacy *_preset_id fields for full backwards-compatibility — the schema validator normalises bare integer ids into PresetRef(source='local', id=str(int)) so the dispatcher only deals with one shape. New preset_resolver service fetches the preset content per source: cloud via BambuCloudService.get_setting_detail (unwraps the setting envelope, falls back to top-level on minor shape variants), local from the DB (existing path), standard via a minimal {inherits: <name>, from: "system"} stub that the sidecar's bambuddy/profile-resolver branch flattens against BUNDLED_PROFILES_PATH/<category>/<name>.json — no preset-content round-trip needed for the standard tier. Permissions: the listing route gate matches the slice action itself (LIBRARY_UPLOAD) so any user who can slice can populate the dropdowns; the cloud branch has an independent CLOUD_AUTH check inside the fetch helper — a user holding LIBRARY_UPLOAD but not CLOUD_AUTH doesn't see the cloud tier (and can't slice with a cloud preset, returns 403) even if a leftover User.cloud_token survived a permission revocation. SliceModal (frontend): grouped <optgroup> per tier with localised section headers, default-selection follows the cloud > local > standard priority on first load, cloud-status banner with three variants (sign-in / expired / unreachable) only when the status isn't ok. Sidecar (orca-slicer-api fork, bambuddy/profile-resolver branch): new GET /profiles/bundled walks BUNDLED_PROFILES_PATH/{machine,process,filament} and returns instantiable presets only (instantiation: "true"), filtering out abstract bases like fdm_filament_pla so the dropdowns only offer things a user can actually pick. Tests: 17 unit tests for the listing endpoint helpers (dedup priority + per-slot scoping + order preservation, all four cloud_status states, CLOUD_AUTH defence-in-depth with token lookup short-circuit, per-user cache isolation, token-change cache invalidation, sidecar-unreachable fallback), 11 unit tests for the source-aware resolver (standard inherits-stub shape, local DB lookup with preset_type validation, cloud envelope unwrapping with both standard and top-level shapes, cloud auth-error → 401, cloud CLOUD_AUTH defence, slot dispatch routing), 6 schema tests for SliceRequest covering legacy bare-int normalisation and new source-aware refs and explicit-ref-wins-over-legacy precedence, 12 frontend tests for SliceModal covering tier-priority auto-selection, <optgroup> grouping, fallback when higher tiers are empty, source-aware payload on submit, manual override across tiers, archive-vs-library routing, error display, and all three banner variants. All 3391 backend + 1531 frontend tests pass.
Server-side slicing via OrcaSlicer / Bambu Studio sidecar — Bambuddy can now slice models without a desktop slicer installed. New optional slicer-api/ Compose stack runs HTTP wrappers around the OrcaSlicer and/or Bambu Studio CLI; Bambuddy's File Manager and Archives pages get a Slice button that picks a printer / process / filament preset and dispatches a background slice job whose result lands as a new .gcode.3mf in the same library folder (or as a new archive when the source was an archive). Settings → Workflow gets a new Slicer card: pick the preferred slicer, toggle "Use Slicer API" on, and paste the sidecar URL — Slice buttons across File Manager, Archives, and MakerWorld then route through the API instead of the OS slicer URI scheme. Status updates come from a global SliceJobTrackerProvider that polls /api/v1/slice-jobs/{id} and surfaces a single toast per job (queued → running → completed / failed) plus auto-refreshes the file or archive list on success — slicing one file no longer pins the modal. Server side, a fresh in-memory dispatcher (backend/app/services/slice_dispatch.py) runs jobs as asyncio.create_tasks with a 30-minute retention sweep, and the routes (POST /library/files/{id}/slice, POST /archives/{id}/slice) return 202 immediately with {job_id, status, status_url} instead of holding the request open through a multi-minute slice. The CLI bridge (backend/app/services/slicer_api.py) distinguishes 4xx (SlicerInputError), 5xx (SlicerApiServerError), and connection failures (SlicerApiUnavailableError) so 3MF inputs can transparently retry with embedded settings when the sidecar's --load-settings path segfaults on the input — empirically required for OrcaSlicer 2.3.x + H2D and signalled to the UI via used_embedded_settings: true. Sliced output is forced to .gcode.3mf so File Manager picks up the embedded thumbnail, the print_name is dropped from saved metadata so the displayed filename matches what the user picked, and file_type="gcode" paints the badge blue. The polling endpoint GET /api/v1/slice-jobs/{id} is gated on LIBRARY_READ since job IDs are sequential and the body leaks source filenames + resulting library/archive IDs. The sidecar itself builds from a fork of AFKFelix/orca-slicer-api (maziggy/orca-slicer-api@bambuddy/profile-resolver) which adds the inherits: chain resolver, from: "User" → "system" rewrite, # clone-prefix strip, and sentinel-value strip empirically required to slice real OrcaSlicer GUI exports without segfaulting the CLI; the Compose file uses Docker's git-build-context so users don't clone it manually. Default ports are 3003 (orca) and 3001 (bambu-studio) — 3000/3002 are skipped because Bambuddy's virtual-printer feature owns them. 10 backend integration tests cover sync validation (404/400), happy-path enqueue, preset-error → failed job, sidecar unreachable, the 3MF embedded-settings fallback, STL no-fallback, and the strip-before-forward path; 5 new frontend tests for the SliceModal cover preset gating, library + archive enqueue paths, error display, and preset-load failure. New i18n keys under slicer.* and settings.slicer.* across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Slicer integration is opt-in: if "Use Slicer API" stays off, the existing "open in desktop slicer via URI" flow is the default and unchanged.
Per-spool category + low-stock threshold override (#729 — minimal version) — Two new fields on the spool form: a free-text Category (with autocomplete from categories already in use, so users naturally re-use "Production" instead of accidentally typing "production" / "prod") and a per-spool Low-stock threshold (%) override that defaults to the global setting if left blank. Powers the "I want to differentiate critical spools from prototype spools and alert at different thresholds" use case from the issue without taking on the full multi-tag taxonomy + auto-apply-rules + per-tag alert system the ticket originally proposed (which would have been ~5x the work for the same underlying value). Inventory page gains a Category filter chip — only renders once at least one spool carries a category, otherwise hidden so the chip row stays uncluttered. Low-stock counts in the stat-card and the "Low Stock" filter both honour the per-spool override (so a "Production" spool with override = 90% will count as low-stock at 80% remaining even when the global threshold is 20%). 50-char cap on category, 1-99% range on threshold (0 and 100 are both rejected as footguns). 9 new backend schema-validation tests covering the field defaults, partial-update behaviour, range/length rejection; 2 new frontend tests confirming the per-spool threshold pulls in spools the global threshold misses, and that the category filter chip stays hidden until at least one spool has a category. Localised across all 8 UI languages with full translations. The full multi-tag taxonomy from the original issue isn't going forward; if demand for it grows past the current 3 thumbs-up the design can layer on top of these fields without breakage.
Per-event ntfy priority (#990) — ntfy supports a Priority header (1=min, 2=low, 3=default, 4=high, 5=urgent) that drives sound, visibility, and push behaviour on the receiving device, but the existing notifier sent every event at the server default — so a "50% complete" ping looked identical to "print failed" or "printer offline". The Add/Edit Notification modal now renders a per-event "ntfy Priority" section (visible only when the provider type is ntfy) listing each enabled event with its own Min / Low / Default / High / Urgent dropdown; selections persist into the provider's config.event_priorities map and the backend emits a matching Priority: N header on the ntfy POST/PUT request (including the image-attachment path). Events not explicitly mapped, malformed values, and out-of-range values (0, 6, "abc", null) all fall through to ntfy's server-side default — there is no clamping, so a misconfigured value never silently sends at the wrong urgency. Test sends (no event_type context) deliberately omit the header so the test path cannot accidentally page someone at urgent priority. Existing providers without event_priorities are untouched on upgrade. Localised across all 8 UI languages with full translations (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). 6 new backend tests covering header set on mapped event, omitted on unmapped event, omitted when no event_priorities configured, omitted when event_type is missing, ignored for out-of-range / non-numeric values, and propagated through the image-attachment PUT path.
Long-lived camera-stream tokens for HA / Frigate / kiosks (#1108) — The existing ?token=… camera-stream tokens expire after 60 minutes which forced home-automation integrations (Home Assistant cards, Frigate, hallway kiosks) to either refresh on a cron or run with auth disabled. New self-service "Camera API Tokens" panel under Settings → API Keys (also reachable via the existing settings search box — type "camera token" / "frigate" / "home assistant") lets any user holding camera:view mint a long-lived token they can paste once and forget. Revoke uses Bambuddy's standard styled confirmation modal (no window.confirm browser default — same pattern as the rest of the app). Tokens are scoped strictly to camera streaming (no privilege escalation surface — no other endpoint accepts them), formatted bblt_<8-char-prefix>_<32-char-secret>, and stored as a pbkdf2 hash so even a DB dump can't replay them; the plaintext is shown to the user exactly once in a copy-to-clipboard modal (with a document.execCommand('copy') fallback for plain-HTTP LAN deployments where navigator.clipboard is gated by the secure-context requirement). Hard 365-day max — the issue's expire_in: 0 (never) is explicitly rejected because an irrevocable infinite token is a footgun-by-design; UI defaults to 90 days, the cap is enforced both client-side (input clamp) and server-side (validation guard). Owners can revoke their own tokens; admins additionally see an "All users" view for leak triage and can revoke anyone's. The /camera/stream?token=… auth dependency tries the existing 60-min ephemeral row first (no behaviour change for the common browser case) and falls through to the long-lived path, so the SPA's existing camera flow is unaffected. Indexed lookup_prefix keeps verify O(1) per token even on large installs — pbkdf2 only runs against the one candidate row that matches the prefix, never the whole table. New long_lived_tokens table (separate from auth_ephemeral_tokens because the lifecycle is different — user-owned, named, revocable, hashed; and separate from api_keys because that one is for global webhooks with no user FK and a different permission shape). 15 unit tests covering create-validation/scope/expiry rules, verify happy/garbage/expired/revoked/scope-mismatch/prefix-collision paths, list-by-user vs list-all, idempotent revoke; 14 integration tests covering the create-once-then-listing-hides-plaintext contract, the 365-day cap, the auth gate, owner-vs-admin revoke ownership rules, and that the long-lived token verifies through the same camera-stream auth dependency the route uses (and that revoke immediately invalidates it). 6 frontend tests covering list render, empty state, create-then-shown-once flow, days-input clamp, revoke-with-confirm, and revoke-cancelled paths. New cameraTokens.* keys across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).
Tailscale integration for virtual printers (builds on #1070 by @legend813) — Opt-in per-VP Tailscale toggle brings each virtual printer into the tailnet, so it's reachable from any tailnet device over a private WireGuard tunnel without port forwarding or public exposure. When enabled, Bambuddy provisions a Let's Encrypt cert for the VP's MagicDNS hostname via tailscale cert and the MQTT/FTPS listeners serve it. Slicer-side caveat worth knowing up front: both Bambu Studio and OrcaSlicer only accept IP addresses (not hostnames) in the Add Printer dialog, so the LE cert's hostname validation doesn't apply — users still need the Bambuddy CA imported into the slicer, same as LAN mode. The practical benefit here is the private tunnel (remote access without DDNS / port forwarding / public exposure), not cert-import elimination. Default is opt-out (toggle off) so users without Tailscale don't see cert-provisioning attempts or log noise. When a user flips the toggle on a host without a working Tailscale binary, the backend returns 409 tailscale_not_available and the UI reverts + surfaces a specific toast pointing at the setup steps (install Tailscale → tailscale up → tailscale set --operator=<user> → enable HTTPS in the tailnet admin console). Docker image now ships the tailscale CLI pre-installed; users wire up by uncommenting the /var/run/tailscale/tailscaled.sock volume mount in docker-compose.yml. The MagicDNS hostname is surfaced on the VP card with a copy-to-clipboard button (modern navigator.clipboard in secure contexts, document.execCommand fallback for plain-HTTP contexts with textarea cleanup in finally). Cert renewal runs daily in-process and restarts only the affected VP's TLS listeners. New i18n keys virtualPrinter.tailscaleDisabled.{title,description} + virtualPrinter.toast.{tailscaleNotAvailable,copyFailed} across all 8 locales with full translations. 3 new backend integration tests for the 409 guard, 2 unit tests for the _cancel_restart_task self-await guard, 4 unit tests for the settings-dedupe migration, and 3 new frontend tests for the clipboard fallback path. Thanks to @legend813 for the original opt-out toggle PR that this was built on top of.
Library Trash Bin + Admin Bulk Purge + Auto-Purge (#1008) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than N days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new Auto-purge setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New library:purge permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable deleted_at column on library_files with an index (dialect-aware migration: DATETIME on SQLite, TIMESTAMP on PostgreSQL, since raw DATETIME is SQLite-only syntax); every LibraryFile query site now routes through a new LibraryFile.active() classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to @cadtoolbox for the proposal and the follow-up answers that tightened the spec.
Archive Auto-Purge (#1008 follow-up) — Settings → Archives now has an auto-purge toggle plus a Purge archives now action on the Archives page header (next to Upload 3MF, mirroring File Manager's placement) that hard-deletes print archives not printed within a configurable window (default 365 days, min 7, max 10 years) with the same live-preview modal as the library purge. Reprinting an archive reuses the row and updates its completed_at, so the purge honours the most recent print completion — a two-year-old archive you reprinted yesterday is not eligible for deletion. Unlike the library trash, archives are hard-deleted: print history is a decaying timeline, so there is no trash bin intermediate; download or favourite anything you want to keep first. The sweeper runs on the same 15-minute scheduler as the library trash but throttles actual purge runs to once per 24h so a tight tick cadence doesn't churn the DB. Each purged archive goes through the existing safety-checked ArchiveService.delete_archive path so the 3MF, thumbnail, timelapse, source 3MF, F3D, and photo folder are all cleaned up together with the DB row. Gated by a new dedicated archives:purge permission (Administrators group by default, backfilled on upgrade); 9 new backend integration tests; localised across all 8 UI languages.
MakerWorld Integration — Paste any makerworld.com/models/… URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click Save or Save & Slice in Bambu Studio / OrcaSlicer per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste. LibraryFile now tracks source_type + source_url, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links).
Endpoint route (non-obvious, ~1 day of reverse engineering) — Pr0zak/YASTL#51 documented that makerworld.com-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked at api.bambulab.com. The working path turned out to be GET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId} with Authorization: Bearer <cloud_token> — a different service (iot-service, not design-service) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…). The modelId query param is the alphanumeric identifier (e.g. US2bb73b106683e5) that only appears in the design response body, not the integer designId from the /models/{N} URL — so the import flow fetches design metadata first, reads modelId, then calls iot-service. S3 presigned URLs must be fetched with urllib.request (not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it with SignatureDoesNotMatch 400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path.
UI and UX features — per-plate picker with inline Save / Save & Slice in Bambu Studio / OrcaSlicer buttons, Import all to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (View in File Manager / Open in Bambu Studio / Open in OrcaSlicer / Remove from library), per-plate delete via the standard Bambuddy confirm modal (no browser confirm()), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (/library/files/{id}/dl/{token}/{filename}) so the handoff works even with auth enabled. Localised across all eight UI languages.
Security hardening — the MakerWorld description HTML is user-authored and goes through DOMPurify.sanitize() before dangerouslySetInnerHTML. <img> tags inside summaries are rewritten to route through Bambuddy's /makerworld/thumbnail proxy so the SPA's img-src 'self' data: blob: CSP stays unwidened. Thumbnail proxy now uses follow_redirects=False (the host-allowlist guarantee is only meaningful on the initial URL — a 302 to 169.254.169.254 would otherwise bypass it). The 3MF CDN fetch sends only User-Agent — the Bambu Cloud bearer is never forwarded to the CDN. S3 presigned-URL fetch uses a urllib.request opener with a no-op HTTPRedirectHandler for the same reason. Filenames from MakerWorld responses are os.path.basename'd before persisting, so a malicious name: "../../evil.3mf" cannot surface a path-traversal string into the DB / UI (on-disk storage uses a UUID filename regardless). New routes respect the MAKERWORLD_VIEW (resolve / recent-imports / status) and MAKERWORLD_IMPORT (import) permissions. SSRF guard on downloads rejects any host that isn't makerworld.bblmw.com, public-cdn.bblmw.com, or a .amazonaws.com subdomain.
Test coverage — 46 unit tests for services/makerworld.py (header shape, API base, get_design/get_design_instances/get_profile, get_profile_download 200/401/403/404/no-token, download_3mf SSRF rejection of 4 hostile hosts, S3 path delegation, CDN path with minimal headers, size-cap, _download_s3_urllib happy/redirect/size/network paths, fetch_thumbnail with follow_redirects=False); 19 route tests (/resolve, /import with folder autocreation + explicit folder + dedupe + filename basename + profile_id response, /recent-imports with empty-list / ordering / pydantic shape / limit clamping, _canonical_url unit); 12 frontend tests (button labels, slicer-name interpolation, URL-change detection, inline post-import actions, Recent imports rendering, DOMPurify <script> strip).
SpoolBuddy kiosk no longer shows main-app toasts — the global ToastProvider (in App.tsx) wraps both the main app routes and the SpoolBuddy kiosk routes, so the background-dispatch progress overlay (job percent, completion summaries, etc.) was rendering on the kiosk display alongside any in-flight prints. Added a setViewportSuppressed setter on the toast context; SpoolBuddyLayout flips it on mount and restores on unmount via a single useEffect. The state machine, dispatch-event subscription, and other tabs' toast UIs are untouched — only the visible viewport is hidden while a kiosk display is active. Trade-off accepted: kiosk-local one-shot toasts (plate-clear confirmation, quick-add errors) are also hidden, but the kiosk's UI already provides direct visual feedback (the plate-ready row vanishes on click; quick-add failures surface in the modal). Updated SpoolBuddyLayout.test.tsx to wrap in ToastProvider and expand its lucide-react mock with the icons ToastContext imports. 2 new regression tests: ToastContext.test.tsx::viewport suppression pins the suppressed-viewport hidden class toggle without affecting the underlying state, and SpoolBuddyLayout.test.tsx::suppresses the global toast viewport while mounted confirms the kiosk layout flips suppression at mount and cleanup.
Background-dispatch toast no longer reads as "frozen at 100%" for fast uploads — small files (a few hundred KB to a printer over LAN) finish FTP upload in <500ms, so the progress bar would jump to 100% and then sit there for ~1-2s while the printer's MQTT confirmation landed and the success toast replaced the dispatch toast. Now, when the byte-count reaches the total but the job status is still `processing` (i.e. upload done, awaiting printer ack), the byte-count line is replaced with "Awaiting printer..." and the progress bar gets `animate-pulse` to indicate continued activity. Translated across all 8 locales (`backgroundDispatch.awaitingPrinter`). 2 new tests in `ToastContext.test.tsx::background dispatch — upload-done UX` cover the threshold (`uploadProgressPct >= 99.9withprocessingstatus switches to "Awaiting printer..." + pulse) and the in-flight case (50.0%` keeps the byte/percent counter, no pulse).
SpoolBuddy kiosk: "Plate ready" pills under the printer status badges — when any printer reports awaiting_plate_clear=true, a small amber pill appears in the dashboard's left column, sized to match the existing online/offline printer badges. Each pill shows the printer name plus a "Clear" action; tapping it calls POST /printers/{id}/clear-plate and optimistically removes the pill from the UI before the WebSocket round-trip lands. Multi-printer setups (e.g. four H2Ds finishing at once) wrap inline via flex-wrap so the dashboard stays compact instead of pushing everything else off-screen. The kiosk's API key already passes the printers:clear_plate permission gate via the existing _APIKEY_DENIED_PERMISSIONS denylist (the permission is intentionally not denied — clear-plate is an inventory-flow operation, not an admin one), so no auth wiring changes were needed. Translated across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). 5 new regression tests in SpoolBuddyDashboard.test.tsx::plate-clear row cover: row hidden when no printer is pending, mixed pending/non-pending printers (only the pending one gets a pill), title attr + pill text content + Clear label all rendered, clicking calls api.clearPlate(printerId), the optimistic cache write makes the row vanish without waiting for a refetch, and three concurrent pending printers wrap inline in the same flex-wrap container. The mock useTranslation was upgraded to support {{var}} interpolation so future tests can assert on rendered i18n strings with arguments.
Per-request trace ID column on every log line, plumbed through HTTP access log + application logs + response headers — Builds on the new uvicorn-access-log-into-bambuddy.log change below: the access line tells you who called an endpoint, but until now there was no way to tie that line to the application records emitted on the server side while handling that request. A new FastAPI middleware (trace_id_middleware in main.py, sourced from backend.app.core.trace) stamps each request with a fresh 8-char hex ID (or honours a sane inbound X-Trace-Id header for cross-system correlation), stores it in a ContextVar so any code in the request's call stack can read it, echoes it on the response as X-Trace-Id, and a new TraceIDFilter injects it into every LogRecord so the format string [%(trace_id)s] resolves to the right ID for the right request. ContextVars (rather than request.state) are the right plumbing here because asyncio copies the current context into every asyncio.create_task, so background work spawned from inside a request inherits the trace ID without explicit threading; the logging filter has no access to the FastAPI request object regardless. Records emitted outside any request scope (startup, MQTT callbacks, scheduler) get a stable - placeholder so the column stays visually aligned and missing values are obvious in grep. Inbound X-Trace-Id is hard-validated against a strict whitelist ([A-Za-z0-9_-]+, max 64 chars) before being honoured — a hostile or buggy caller cannot smuggle log-injection payloads (newlines, control chars, megabyte blobs) into bambuddy.log via the trace-ID column; values that fail the gate silently trigger a freshly minted server-side ID rather than failing the request. Middleware is decorated AFTER auth_middleware on purpose: Starlette stacks @app.middleware decorators LIFO so the last-decorated runs first inbound, making trace stamp the OUTERMOST layer — auth log lines and every record emitted on the way down to and back from the route handler all carry the same ID. Output now looks like 2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200 paired with the route handler's 2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent stop print command — one grep a4f3b1e7 away from the full causality chain. 30 new tests across tests/unit/test_trace.py (placeholder when no request scope, filter copies ContextVar value onto records, ID propagates into spawned tasks via asyncio context copy, concurrent requests don't leak IDs into each other, generator produces unique hex IDs, hostile payloads rejected by validator, max-length boundary, dash/underscore variants accepted) plus tests/integration/test_trace_middleware.py (X-Trace-Id header echoed on response, body and header IDs match, each request gets a unique ID, generator format stays short hex, safe inbound IDs honoured, hostile inbound IDs replaced, overlong inbound IDs replaced, ContextVar reset cleanly after request).
<FilamentHoverCard> / SpoolBuddy's slot-action sheet had two stacked filters that together blocked a real workflow: (1) AssignSpoolModal only listed spools whose tag_uid AND tray_uuid were both null, hiding any Bambu Lab spool that had been auto-created from RFID or scanned via SpoolBuddy NFC; (2) FilamentHoverCard rendered its inventory section (assign + unassign affordances) only when the slot's vendor was not Bambu Lab, so even if you fixed the picker the button to open it wasn't visible on a BL slot. The use case both filters blocked: a user who has a Bambu Lab spool sitting in their inventory but doesn't want to scan it via SpoolBuddy NFC each time and just wants to pick it from the list. Both gates are gone now: the modal lists every spool that isn't already taken by a different (printer / ams_id / tray_id) tuple, and the hover-card inventory section renders for every vendor including Bambu Lab. The AMS-vs-external-slot distinction in the modal also collapsed — external slots (amsId 254/255) used to be the only path that allowed picking a tagged spool, and that special-case is now redundant. Empty slots (<EmptySlotHoverCard> in Bambuddy, slotActionPicker.tray === null in SpoolBuddy) lost their assign affordance entirely: a physically empty slot has no spool to attach an inventory record to, and offering the action there only led to users assigning the wrong spool to a slot the printer hadn't actually loaded yet — assignment now requires a loaded slot. The i18n.inventory.noManualSpools key (whose copy talked specifically about "manually added spools") was renamed to inventory.noAvailableSpools with new copy ("No spools available. Add a spool to your inventory or unassign one from another slot first.") since the empty-state premise changed; localised across all 8 languages with full translations. 5 net-new frontend tests in __tests__/components/FilamentHoverCard.test.tsx (assign/unassign buttons render for vendor: 'Bambu Lab', non-BL vendors unchanged, EmptySlotHoverCard renders no assign affordance, configure button still works on empty slots) plus the existing AssignSpoolModal.test.tsx "filters out BL spools" expectation was inverted to match the new contract and the empty-state test reworked to exercise the only remaining trigger (every spool taken by another slot).Inventory: "Delete Tag" button renamed to "Clear RFID Tag" (#729 follow-up) — The reporter mistook the button for a taxonomy-tag delete (it actually clears the RFID tag UID/UUID off the spool record so the row can be re-attached to a different physical spool). Renaming it to "Clear RFID Tag" + the success toast to "RFID tag cleared" removes the ambiguity. No behaviour change. Localised across all 8 UI languages with full translations.
Nozzle icon on the dual-nozzle status card (#1115) — the dual-nozzle active-extruder card on the printer status bar was the only card in that row without a theme icon (the Nozzle/Bed/Chamber temperature cards all carry a thermometer icon), which left the row looking visually uneven on H2D / H2S / H2C. Adds a small schematic nozzle icon (filament body + heater block + tip) above the L/R diameter labels, styled in amber-400 to match the card's active-extruder accent. SVG design contributed by @m4rtini2.
Slice tracker no longer shows the "embedded settings used" warning toast — SliceJobTrackerContext was emitting a yellow warning toast on every completed slice whose result carried used_embedded_settings: true (the auto-fallback path that fires when the sidecar's --load-settings triplet rejected the input). For 3MF inputs that fallback fires on essentially every slice in production (BambuStudio CLI segfaults silently on --load-settings over 3MF, even with the broader strip applied — verified end-to-end with the new sidecar stderr capture), so the toast was firing on essentially every completed slice and adding noise without a useful action. The used_embedded_settings flag still lands on SliceResponse / SliceArchiveResponse for tests + observability (test_library_slice_api.py:347 continues to pin it); only the user-facing toast goes. slice.fallbackUsedEmbedded removed from all 8 locale files in the same change.
Settings page: permission-gated instead of admin-only — the Settings sidebar entry has always been visible to any user holding settings:read, but the route guard required admin role, so a non-admin with settings:read would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with settings:read can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (users:read, groups:update, oidc:*, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (groups:create for /groups/new, groups:update for /groups/:id/edit), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.
i18n: full key parity across all 8 locales — en is the reference; every other locale (de, fr, it, ja, pt-BR, zh-CN, zh-TW) is checked identically and any drift fails CI. Until now, the parity script at frontend/scripts/check-i18n-parity.mjs only enforced parity for de / zh-CN / zh-TW and demoted fr / it / ja / pt-BR to an "informational" tier — drift was reported but never gated. Result: 78 missing keys in fr and it, 66 in pt-BR, 54 in ja, accumulated across every release that added new en strings. Backfilled real translations (not English fallbacks) for every gap: login.resetPassword.* (12 keys, fr/it/ja), printers.firmwareModal.* extension (7 keys × 4 locales) from the firmware modal redesign, the full settings.spoolbuddy.* device-control admin block (~40 keys) for unregister / reboot / shutdown / update / restart confirms, the kiosk-side spoolbuddy.settings.* block (13 keys × 4) for backend & auth + diagnostics, and the new virtualPrinter.archiveNameSource.* block from this release (#1152). The parity script itself dropped the two-tier STRICT / info machinery — every non-en locale is now treated equally — so any future feature that adds en strings without translating them everywhere fails CI uniformly. All 8 locales sit at 4492 leaves.
origin/main and silently no-op'd whenever the latest release wasn't on main — _perform_update ran git fetch origin main && git reset --hard origin/main verbatim, regardless of which version GitHub's releases API reported as latest. So during any beta release cycle (when 0.2.4b1 lives on its own branch and main still points at the previous stable), users on the prior stable who clicked Apply Update saw the GUI report success but actually stayed pinned to the old main HEAD. The pre-existing pip-cwd and SSH-origin-clobber bugs in this same code path made it worse, but the underlying limitation was that the updater literally couldn't reach a non-main release. Fix: extract _discover_target_release(db) (mirrors the same release-API + include_beta_updates selection logic the GUI's update-check route already uses), pass the resolved tag (e.g. v0.2.4b1) into _perform_update(target_ref), and git fetch --prune --tags origin && git reset --hard <tag>. The fetch step now pulls --tags so the tag ref is locally resolvable; the reset takes whatever ref the caller resolved instead of a hardcoded branch. Also makes apply_update return a clear error if no release matches the user's channel rather than silently kicking off an update that can't land. Three new regression tests in test_updates_api.py cover (1) _perform_update resets to the caller-supplied ref and fetches tags, (2) apply_update plumbs the discovered tag through to _perform_update, (3) apply_update errors out cleanly when discovery returns no candidate.origin on developer checkouts — The in-app Apply Update path unconditionally ran git remote set-url origin https://github.com/maziggy/bambuddy.git before fetching, on the assumption that systemd service users wouldn't have SSH keys configured. That assumption holds for production native installs, but anyone testing the upgrade flow against their own development checkout (where origin is legitimately git@github.com:maziggy/bambuddy.git and authentication is via SSH keys) had their SSH origin silently rewritten to HTTPS — so the very next git push prompted for HTTPS credentials they didn't have configured and bounced. Fix: the updater now reads the current origin first via git remote get-url, parses the URL into an (owner, repo) pair (handling all four canonical forms — git@github.com:owner/repo[.git] and https://github.com/owner/repo[.git]), and only rewrites if it doesn't already resolve to maziggy/bambuddy. Native installs with no remote set, or origins pointing at a fork / wrong repo, still get reset to the canonical HTTPS URL. Three regression tests in test_updates_api.py cover the parser, the SSH-preservation case, and the fork-rewrite case so a future refactor can't regress either side of the contract.pip install and the new dependencies never landed — On a native install (where systemd sets DATA_DIR=$INSTALL_PATH/data), the in-app Apply Update button shipped the new code via git reset --hard origin/main correctly but then logged ERROR: Could not open requirements file: [Errno 2] No such file or directory: 'requirements.txt' and continued without installing the new deps. pip install -r requirements.txt was running with cwd=settings.base_dir, which on a native install resolves to the data dir (e.g. /opt/bambuddy/data), not the source-code dir (/opt/bambuddy); pip doesn't walk up looking for the requirements file the way git walks up looking for .git, so the file wasn't found, the install was effectively skipped, and the user ended up with new code but stale dependencies — which surfaces as cryptic import / runtime errors on the next restart. Same bug affected the optional npm install / npm run build step (it tested frontend_dir = base_dir / "frontend", which doesn't exist on native installs, and silently fell through to the pre-built static files). Fix: introduce settings.app_dir alongside settings.base_dir pointing at the source-tree root, and run pip install and the npm steps with cwd=settings.app_dir. Git operations keep using base_dir since they already worked (git walks up to find .git). Docker users were unaffected — Docker doesn't use the in-app updater (image pull replaces it). Regression test in test_updates_api.py mocks every subprocess invocation in _perform_update, captures their cwd, and asserts the pip step runs in app_dir and that requirements.txt actually exists there, so a future refactor that re-introduces cwd=base_dir for the pip step fails CI before another user trips over it.cannot drop table printers — Settings → Backup → Restore on a Bambuddy running against external Postgres failed with asyncpg.exceptions.DependentObjectsStillExistError: cannot drop table printers because other objects depend on it whenever the live database carried orphan tables from removed features — for example legacy spoolman_slot_assignments / spoolman_k_profile from an earlier Spoolman integration that has since been removed from the ORM but whose tables and *_printer_id_fkey constraints still sat in the live schema, pointing at printers. The restore path (_import_sqlite_to_postgres in settings.py) called metadata.drop_all, which only enumerates tables defined by SQLAlchemy ORM models and emits plain DROP TABLE (no CASCADE); Postgres correctly refused to drop printers while external constraints still referenced it, the entire restore aborted before any rows landed, and the user was left without a working DB. The drop phase now executes DROP TABLE … CASCADE on every table in the public schema (via a pg_tables-iterating PL/pgSQL DO block, after FKs have been stripped from the ORM metadata) before metadata.create_all rebuilds the schema. CASCADE is the right tool for a destructive restore — the user has explicitly chosen to wipe the DB and replace it from backup, so taking out orphan tables alongside ORM tables is correct behaviour, not surprise data loss. SQLite restores are unaffected (they go through a separate path). Discovered while attempting to restore a 0.2.4b1 backup onto a Postgres instance that had been upgraded across the Spoolman integration rewrite. Two regression tests in test_postgres_restore_drop_cascade.py mock the Postgres engine, run _import_sqlite_to_postgres against a tiny SQLite source, and assert (1) the captured SQL stream contains a CASCADE-aware iteration over pg_tables (so a regression to metadata.drop_all fails CI loudly, before another user trips on it) and (2) the CASCADE drop is scoped to schemaname = 'public' so a shared Postgres instance holding non-Bambuddy data in other schemas isn't taken out by a restore. All 44 existing settings-API tests still pass unchanged.H2D Pro multi-plate dispatch double-/triple-fire (#1157) — Scheduling 3 plates of a multi-plate file to the same H2D Pro caused the scheduler to fire all three project_file commands within ~60 seconds, even though the printer hadn't transitioned out of FINISH for the first one yet. The H2D Pro can sit at FINISH for 80–210 s after accepting project_file before the gcode_state flips to PREPARE, and during that window the existing DB busy_printers seed (querying queue items in printing status) was empirically missing the in-flight item — observed in support logs as items 139/140/141 all dispatching with status='printing' yet only the third actually triggering a state transition. User-visible symptoms: layer count flapping, all queued plates showing as printing simultaneously, MQTT disconnect storms (33 in a single 5-minute window), eventual print failure. Root-cause fix is a defensive in-memory dispatch hold layer in print_scheduler.py: when _start_print succeeds we record (printer_id, dispatched_at, pre_state, pre_subtask_id), and the next check_queue tick adds that printer to busy_printers until either (a) the watchdog observes a state/subtask transition (success path — release immediately past a 60 s minimum cooldown), or (b) a 180 s hard timeout expires (escape hatch for lost MQTT sessions). The minimum cooldown also prevents a spurious double-dispatch if the printer pulses through PREPARE→RUNNING→PREPARE in the first second after acceptance. The hold is purely additive — sits alongside the existing seed query and _is_printer_idle checks, doesn't depend on DB row visibility, doesn't depend on on_print_complete firing correctly. Per-printer isolation: a hold on printer A never blocks printer B. Edge cases covered by 12 new unit tests (test_scheduler_dispatch_hold.py): no-pre-state fallback (printer was offline at dispatch time), status-unavailable keeps hold (printer disconnected post-dispatch — don't release on missing data), idempotent release, hard-timeout self-cleanup, transition-during-cooldown still holds. The 90 s watchdog still owns the unhappy-path revert (queue item back to pending for retry) — this fix runs alongside it, not instead of it. All 179 existing scheduler tests still pass unchanged.
Project picker UX in archives (#1151) — The "Add to Project" submenu in the archive context menu was unusable past the visible fold once a project library exceeded the 300px scroll cap: any wheel scroll, arrow-key navigation, or scrollbar click slammed the entire context menu shut. Root cause was a capture-phase document.scroll listener in ContextMenu that fired on internal submenu scrolls too — the listener now checks menuRef.current.contains(e.target) and ignores scrolls inside its own subtree. Project lists are now sorted alphabetically by name (localeCompare) at every assignment site (Archives context-menu submenu ×2, BatchProjectModal, EditArchiveModal, "review new uploads" panel, FileManagerPage project-picker) instead of newest-first from the API. The Archives "Add to Project" submenu and BatchProjectModal both gain a search input (rendered only when there are >5 projects, so small libraries stay clean) that filters the list by name as you type — Enter picks the first match. New archives.menu.searchProjects i18n key in all 8 locales (en/de fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow).
OIDC auto_link_existing_accounts now works with custom email claims (Azure Entra ID) (#1088) — auto_link_existing_accounts was previously blocked unless both email_claim='email' and require_email_verified=True. This also rejected Azure Entra ID configurations using preferred_username or upn as the email claim — the recommended setup for that provider, which does not send email_verified. The guard now only blocks the genuinely unsafe combination (Fall B): email_claim='email' + require_email_verified=False. Custom-claim configurations (Fall C) never consult email_verified at all, so there is no verification-bypass risk on that path. All five enforcement layers (DB CHECK constraint, schema validators for create and update, route combined-state guard, DB migration for existing installations) have been updated consistently. Security note: custom claims are safe for auto-link only when the claim value is tenant-administered. If your IdP allows end users to self-assert the claim's value, do not enable auto-link. An in-app warning is shown in the OIDC provider form when this combination is configured.
OIDC settings form: "Require email verified" toggle no longer jumps layout when auto-link is enabled — When Auto-link existing accounts was toggled on, the shorter description text caused the Require email verified toggle to reflow next to Auto-link in the flex container instead of staying on its own row. Both toggles now have w-full and always occupy a full row regardless of description length.
P1P print dispatch failed with 0500_4003 "can't parse print file" when the printer was slow to acknowledge (#1150, reported by @d3ni3) — On a P1P at firmware 01.10.00.00 the printer can take up to ~135 seconds to actually start parsing a freshly uploaded .3mf after the MQTT project_file command lands; FTP STOR returns 226 cleanly and the upload is intact, but gcode_state stays at IDLE and subtask_id doesn't advance until the printer's slow internal parse completes. Both dispatch watchdogs (_verify_print_response in background_dispatch.py and _watchdog_print_start in print_scheduler.py) interpreted the missed transition as a half-broken MQTT session — the original #887/#936 condition where telemetry kept arriving but our publishes were silently swallowed — and called force_reconnect_stale_session to wipe paho's QoS-1 queue and reconnect with a fresh client_id. That reconnect mid-parse is precisely what makes the P1P emit 0500_4003: the new MQTT session interrupts the in-progress parse on the printer side and the printer reports the file as unparseable. The repro: send a print job, wait 15 seconds while the printer is still parsing, watch the watchdog force-reconnect, watch the printer fail with the parse error, retry — same loop. Sending the same file from BambuStudio worked because BambuStudio doesn't reconnect MQTT mid-parse. The fix uses the printer's gcode_file field as a definitive discriminator between #1150 (slow parse) and #887/#936 (half-broken session), since both look identical from telemetry alone: in both cases push_status keeps flowing, state stays unchanged, and subtask_id stays at the pre-dispatch value. The distinguishing signal: when the project_file command actually lands on the printer side, the printer's gcode_file field updates in push_status to reflect the newly-uploaded file; if the publish was silently swallowed (#887/#936), the field stays at whatever the printer was previously showing. Both watchdogs now capture pre_gcode_file alongside pre_state and pre_subtask_id from printer_manager.get_status() before sending the publish, then compare against the printer's current gcode_file after the watchdog times out. If the value changed → command landed → log a #1150 warning explaining the skip and leave the MQTT session alone. If the value is unchanged → publish was silently swallowed → fall through to the original force_reconnect_stale_session call so the #887/#936/#1136 zombie-session recovery is preserved exactly. The user-facing dispatch still fails on timeout (correctly — the print didn't start within the timeout window so the job is marked failed), the queue item still reverts to pending so the scheduler can retry, and the next dispatch attempt proceeds against the same intact MQTT session that was about to start the print. Pairs with the 15s → 90s timeout bump that already shipped in commit 9d041868 (the original 15s timeout was a separate v0.2.3.2 limit). Caveat acknowledged in code comments: in a retry-same-file slow-parse scenario the printer's gcode_file looks identical before and after the publish lands, so the watchdog falls through to the original reconnect path and the user still sees 0500_4003 on that specific retry — accepted to avoid breaking the half-broken-session recovery, which is the more impactful regression of the two. 4 new unit tests covering both watchdogs: skip reconnect when gcode_file changed (the #1150 fix), reconnect when gcode_file is unchanged (the #936 protection preserved), skip reconnect when pre_gcode_file=None and current is non-None (printer just connected), reconnect when pre_gcode_file arg is omitted (backward-compat for callers we haven't updated). All 439 existing dispatch / scheduler / mqtt tests still pass unchanged.
3MF profile-driven slicing silently produced wrong-printer output (every 3MF slice fell back to the source's embedded printer regardless of the picked profile) — Two stacked bugs in the slice pipeline. (1) Pre-forward strip removed too much. _strip_3mf_embedded_settings was scrubbing all four embedded Metadata/*.config files before forwarding the 3MF to the sidecar, on the theory that --load-settings would then take precedence cleanly. That theory was wrong: Metadata/model_settings.config carries the plate definitions the CLI needs to map --slice N to a real plate, and slice_info.config / project_settings.config supply baseline config the CLI's StaticPrintConfigs pass needs to even start. Stripping any of them caused the CLI to silently exit immediately after "Initializing StaticPrintConfigs" — exit code 0, no result.json, no stderr — which the sidecar treated as failure and Bambuddy then masked by falling back to slice_without_profiles using the un-stripped bytes (and the source's embedded printer). Net effect: every 3MF slice with profiles silently produced wrong-printer output. The strip is now gone from the slicer dispatch path entirely; original bytes go to the sidecar so --load-settings overrides only the specific fields the user changed (printer/process/filament) while the embedded plate / model definitions remain intact. (2) Standard-tier preset stubs were missing the type field. _resolve_standard in preset_resolver.py emitted {"name": ..., "inherits": ..., "from": "system"} for the bundled tier, but the CLI's preset parser also requires a type discriminator (machine / process / filament) on every loaded settings file — without it the CLI silently rejects with rc=-5 ("input preset file is invalid"), which the same masking fallback then turned into another wrong-printer slice. New _SLOT_TO_PROFILE_TYPE constant maps each slot to its required type, and the stub now emits the right value per slot. Tests: integration test renamed from "strip removes all four configs" to test_3mf_input_forwarded_unmodified_to_sidecar — asserts every Metadata/*.config plus 3D/3dmodel.model is preserved verbatim in the multipart body the sidecar receives. Preset-resolver test updated for the new stub shape; new test_standard_emits_correct_type_per_slot pins each (slot → type) pairing. Pairs with the orca-slicer-api fork's bambuddy/profile-resolver branch which now emits details on its AppError responses and captures CLI stdout/stderr in the failure path so future regressions of this shape produce a real error message instead of a silent fallback.
Sliced-archive card listed every project-wide AMS slot instead of just the filaments the print actually used — slice_and_persist_as_archive previously copied filament_type / filament_color from the unsliced source archive verbatim, which inherited every project-wide AMS slot configured in the source's project_settings.config (16+ swatches on the card for what was actually a 2-color print). The new archive row now reads those fields from the sliced output's Metadata/slice_info.config via ThreeMFParser (which already gates on used_g > 0 per-slot), falling back to the source archive's values only when parsing the new 3MF failed. Test in test_archive_copy.py::test_filament_metadata_only_includes_filaments_with_used_g builds a 4-slot fixture where slots 2 and 4 have used_g=0 and asserts both type and color outputs exclude them.
Slice modal had no warning when the picked printer profile didn't match the source 3MF's bound printer — silent wrong-printer output — Both BambuStudio and OrcaSlicer CLIs reject --load-settings for a printer different from the one the source 3MF was originally bound to (rc=-16 "current 3mf file not support the new printer") because the cross-printer "convert project" flow is desktop-Studio only; the slice would then fall back to embedded settings and produce a file sliced for the wrong printer that errored at print dispatch time with "File was sliced for A1, but printing on H2D". The plates response now exposes source_printer_model (read from project_settings.config's printer_model field, with fallback to stripping the nozzle suffix off printer_settings_id); the SliceModal compares it against the picked printer profile name (substring match against the model prefix, e.g. "Bambu Lab H2D 0.4 nozzle" matches "H2D") and surfaces an inline amber warning explaining the limitation, plus disables the Slice button while the warning is up so users can't dispatch a guaranteed-wrong slice. Cloud presets with arbitrary user-chosen names (e.g. "My Custom X1C") and legacy 3MFs without project_settings.printer_model fall through to no-warning, which is a reasonable default — the user picked it knowingly. New extract_source_printer_model_from_3mf helper in threemf_tools.py with 6 unit tests covering missing/direct/nozzle-stripped/corrupt-JSON paths; 3 frontend tests in SliceModal pinning the warning + disabled-button on mismatch, no-warning on match, and no-warning when the source model is unknown. New i18n key slice.printerMismatch localised across all 8 UI languages.
Sliced output of a "single-color" plate had filaments the user never picked — When a multi-color project (e.g. a MakerWorld Stormtrooper helmet with white shell + grey support filament configured project-wide) was sliced for plate 1 (which only paints with white), the resulting .gcode.3mf's slice_info.config had two filaments — white (the user's pick) and grey (a colour the user never chose). Root cause: the SliceModal was sending only the slots the picked plate consumed, but the slicer CLI requires a profile per project AMS slot — when fewer were supplied, the CLI silently substituted the missing slots from the source 3MF's embedded filament metadata, leaking the original creator's grey support filament into the user's output. Same silent-fallback class as the strip-removal bug. Fix: backend's /filament-requirements endpoint now returns the FULL project AMS slot list with a used_in_plate: bool flag per entry (computed from the cached preview slice for unsliced files; always true for sliced files since slice_info.config already pre-filters by used_g > 0). The SliceModal renders one dropdown per project slot — slots flagged used_in_plate=true are editable as before, slots flagged used_in_plate=false are auto-picked from project metadata via the existing (filament_type, filament_colour) scoring path and disabled with a "— not used by this plate" suffix on the label, so the user only interacts with what matters for their plate while the wire format always carries a profile per project slot. 2 new frontend tests pin the disabled-row rendering and the full-list-on-submit invariant. New i18n key slice.notUsedByPlate localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).
"Analyzing plate filaments…" spinner gave no signal that anything was happening on the first Slice-modal open for an unsliced project file — On a multi-color 3MF without slice_info data, the backend runs a preview slice via the sidecar to discover which AMS slots the picked plate actually consumes. That's the only source of truth: tried two heuristics — painted-face quadtree scan (silently missed extruders when object_id mapping between model_settings.config and 3D/3dmodel.model diverged, surfaced as a single dropdown for a 4-color print) and project-wide AMS list (over-rendered every plate to the project's full slot count) — and both produced wrong counts on real-world multi-color projects. Reverted to preview-slice-as-source-of-truth. The result is cached per (kind, source_id, plate_id, content_hash) so re-opens of the same plate are instant, but the first open on a complex model is a real slice (multi-second to multi-minute). The inline spinner now shows elapsed seconds and, after 5s, a hint explaining that this is a one-time preview slice and re-opens will be instant — addresses the original "is anything happening?" complaint without sacrificing correctness. Project-wide extract_project_filaments_from_3mf remains as a final fallback when the sidecar isn't configured. New i18n key slice.analyzingPlateFilamentsHint localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).
Settings warning when OrcaSlicer is selected as the preferred slicer — OrcaSlicer 2.3.2 and 2.4.0-dev (latest nightly as of 2026-04-28) have two upstream CLI bugs that together block slicing on most Bambu-authored multi-color / H2D 3MFs: (1) a SIGSEGV in the multi-extruder filament-resolution path on painted 3MFs (OrcaSlicer/OrcaSlicer#12426), and (2) the CLI strict-validates parameter values that BambuStudio writes by default — solid_infill_filament: 0, tree_support_wall_count: -1, prime_tower_brim_width: -1 — and exits 238 with Param values in 3mf/config error: ... not in range, even though OrcaSlicer's own GUI tolerates these (OrcaSlicer/OrcaSlicer#13386, filed alongside this change with a minimal repro 3MF). Both bugs verified reproducible on the latest nightly build before filing. Settings → Workflow → Slicer card now renders an inline amber alert under the preferred-slicer dropdown when orcaslicer is the current selection, linking out to both upstream issues and recommending Bambu Studio until upstream fixes land. The OrcaSlicer option is intentionally left pickable rather than disabled — users who only slice STLs or single-color 3MFs aren't affected by either bug, and forcibly disabling would also affect them. Localised across all 8 UI languages (English + German fully translated).
Live progress for the SliceModal's filament-analysis preview slice + URL-decoded filenames in the toast — Two follow-ups to the live slicer-progress feature: (1) the modal's "Analyzing plate filaments…" preview slice (the real slice that fires before profile picking, to discover which AMS slots an unsliced plate consumes) now shows the same stage + percent live updates as the user-initiated slice. The frontend generates a per-(source, plate) request_id, forwards it via a new request_id query param on /library/files/.../filament-requirements and /archives/.../filament-requirements, the backend plumbs it through slice_without_profiles to the sidecar, and a new GET /api/v1/slicer/preview-progress/{request_id} proxy endpoint forwards browser polls to the sidecar's /slice/progress/:requestId (CORS-safe — the browser can't reach the sidecar directly). The inline spinner and a new persistent toast both render Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}} while the preview runs; toast dismisses when filaments arrive. (2) MakerWorld imports were persisting URL-encoded filenames (stormtrooper-helmet%20h2d.3mf) verbatim because MakerWorld's API returns the same percent-encoding it uses on its CDN URLs. The import path now urllib.parse.unquotes both the manifest-supplied name and the URL path-tail fallback before passing to save_3mf_bytes_to_library, plus the frontend defensively decodeURIComponents in the slice toast and analysis-spinner messages so already-imported rows display cleanly without a backfill migration. Falls back to the raw string on malformed encodings (%XY where XY isn't hex). New i18n keys slice.previewToast + slice.previewWithProgress localised across all 8 UI languages (English + German fully translated).
Live slicer progress in the persistent slice toast — The persistent slice toast already showed elapsed time + a spinner so the user could see the slice was still running, but for long slices on complex multi-color models that "is anything happening?" gap could last minutes. Bambuddy now wires up the slicer CLI's structured progress channel end-to-end, so the toast renders concrete stage labels + live percent — Stormtrooper.3mf — Generating G-code (75%) — 47s — through the entire slice. Sidecar (bambuddy/profile-resolver branch of orca-slicer-api): switched the sync /slice route from execFile to spawn so the process can run alongside an FIFO reader; on each request the route generates (or accepts a caller-supplied) requestId, mkfifos ${workdir}/progress.fifo, passes --pipe ${fifo} to the OrcaSlicer / BambuStudio CLI, and reads the structured JSON-line progress events the slicer emits ({"message":"Generating G-code","plate_count":1,"plate_index":1,"plate_percent":80,"total_percent":75}) into a per-process ProgressStore keyed by requestId. New GET /slice/progress/:requestId returns the latest snapshot; entries linger 30s after slice completion so the caller's last poll still reads the terminal "All done, Success" frame instead of a 404. Both slicer forks share the same code lineage from PrusaSlicer's BackgroundSlicingProcess, so OrcaSlicer 2.3.2 and BambuStudio 02.06.00.51 emit identical JSON keys (verified by tracing the binary). Bambuddy backend: slicer_api.slice_with_profiles accepts request_id + on_progress callback and spawns a 1Hz parallel poller that hits the sidecar's progress endpoint while the blocking POST is in flight; SliceDispatchService gained a set_progress(job_id, snapshot) method and a progress field on SliceJob; the slice routes now generate a uuid request_id and wire a callback that forwards each snapshot onto the dispatcher. GET /slice-jobs/:id includes progress on every poll. Frontend: SliceJobTrackerContext reads the new progress field and re-renders the persistent toast with {name} — {stage} ({percent}%) — {elapsed} whenever a useful frame is present, falling back to the existing elapsed-time-only message when the sidecar hasn't emitted anything yet (early "Initializing" phase) or doesn't support progress (older sidecars without the FIFO wiring). 12 sidecar unit tests for the JSON-line parser + ProgressStore (cancellation/grace-window, malformed lines, missing fields), 3 dispatcher tests for set_progress (attach/replace/clear, unknown-job-id silent ignore), 3 slicer_api tests for the form-field forwarding + on_progress callback wire-up + 404 short-circuit, 2 frontend SliceJobTracker tests pinning the new toast format and the no-progress fallback. New i18n key slice.runningWithProgress localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Graceful when the sidecar lacks --pipe support (tested live: OrcaSlicer 2.3.2 + BambuStudio 02.06.00.51 both work; older sidecars without the new endpoint return 404 and the toast cleanly degrades to elapsed-time-only).
No visual indicator while a slice job was running — users couldn't tell if a long slice was still progressing or had hung — Previously SliceJobTrackerProvider emitted one transient toast on enqueue ("Slicing X in the background…") and one on completion ("Sliced X"), with nothing in between. For large multi-color models that take 30s–several minutes to slice, the start toast auto-dismissed after 3s and left a UX dead zone where users would ask "is it still slicing?". The tracker now opens a persistent slice-job-{id} toast with a spinner that updates every second showing elapsed time + phase ("Queued: X — 4s" → "Slicing X — 47s"), then is replaced by the existing transient success/error toast on terminal state. Polling cadence (1.5s) is unchanged — a separate 1Hz tick re-renders just the elapsed-time counter so the toast stays smooth even if the backend is slow to respond. Time format compresses gracefully past 60s ("1m 5s") and 60m ("1h 12m"). 4 new unit tests in SliceJobTrackerContext.test.tsx covering: persistent toast renders at t=0 (no wait for first tick), elapsed time updates each second while running, success completion replaces persistent with transient "Sliced X", failure replaces with transient error toast carrying the sidecar's error_detail. New i18n keys slice.queuedToast / slice.runningToast localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).
MakerWorld URL-paste resolver listed plate instances without showing which printer each was sliced for — MakerWorld's /instances/hits endpoint omits the per-instance compatibility info that lives on design.instances[].extention.modelInfo (compatibility = primary printer the instance was sliced for, otherCompatibility = additional printers the uploader marked it compatible with), so every instance row in the resolved-design preview looked identical and users blindly picked the first one regardless of whether it matched their printer — leading to "I downloaded the H2D version and got A1 g-code" complaints. The resolve route now joins both endpoint payloads by instance ID and forwards both fields onto each hit; the MakerWorld page renders "Sliced for {primaryPrinter}" + (when present) "Also marked compatible: ..." per instance row. Backend tests in test_makerworld_routes.py::TestResolve cover the merge happy path (compatibility lists land on the right hits) and the "missing modelInfo" fallback (older designs / hits without a matching design.instances entry don't crash the response, just lose the optional fields). New i18n keys makerworld.slicedFor / makerworld.alsoCompatible localised across all 8 UI languages.
Moving a file to an external folder updated the DB row but never wrote the bytes to the mount (#1112 follow-up — confirmed by @Carter3DP after testing 0.2.4b1) — Carter's report read "the file appears in Bambuddy but not physically on the external folder", which traced to move_files only updating file.folder_id in the DB while leaving the bytes in the internal library_files_dir. Direct upload to a writable external folder was already fixed in 0.2.4b1; the move path was not. Cross-boundary moves now physically relocate the bytes through a new _move_file_bytes helper. Same-boundary moves (managed → managed) keep the existing DB-only fast path because the file's on-disk location doesn't depend on which managed folder owns it. The helper handles four flows: managed → external (copy bytes to <external_path>/<filename>, flip is_external=True, store the absolute path, unlink the managed source), external → managed (copy bytes into internal storage with a fresh UUID name, flip is_external=False, store the relative path, unlink the external source, recompute file_hash since scan-tracked rows historically carry file_hash=None), external → external (same as managed → external), and managed → managed (DB-only). Copy-then-unlink ordering means a partial copy followed by a failed unlink leaves both copies on disk rather than losing the source if the target write fails halfway through on a flaky NAS mount. Failed shutil.copy2 cleans up partial dest before raising. Defence-in-depth checks block: source on a read-only external mount (move = delete-on-source which a RO mount can't fulfil — would copy-then-fail-to-unlink and silently duplicate the file), filename collisions on the target mount (won't silently overwrite a file the user already has on the NAS), traversal-style filenames after Path.resolve(), missing source on disk, and os.access(W_OK) on the target mount. Each skip carries a structured {file_id, code, reason} entry in a new skipped_reasons field on the response so the UI can surface "5 of 10 files skipped: 3 had filename collisions on the NAS, 2 are no longer on disk" instead of a blank "skipped: 5". The original {moved, skipped} numeric counters are preserved so existing frontend code that only reads those keeps working unchanged. Six new integration tests in test_external_folders_api.py::TestCrossBoundaryMove covering: managed → external relocates bytes (the actual #1112 fix — bytes land on mount, internal source removed, DB row matches reality), external → managed relocates bytes (symmetric path including hash recompute), name collision on target external mount skips with code: "name_collision" and leaves the pre-existing target file intact, source on read-only external mount skips with code: "source_readonly", managed → managed stays DB-only (file_path doesn't change, no shutil.copy), and skipped_reasons is always present (empty list when nothing skipped) so frontend code can treat it as the source of truth without optional-chaining.
bambuddy.log filling with Exception terminating connection ... CancelledError + database is locked cascades on long uploads (#1112 follow-up, surfaced by @Carter3DP's support package) — Two-part fix to a single root cause: Starlette's BaseHTTPMiddleware (which FastAPI's @app.middleware("http") decorator uses under the hood) cancels the inner task scope when a client disconnects mid-request — common on long multipart uploads where the client times out before the server's response. Pre-fix get_db only caught Exception, but CancelledError is a BaseException, so cancellation skipped the rollback path entirely; the SQLite write lock stayed held until the connection was eventually GC'd, producing the (sqlite3.OperationalError) database is locked cascade against runtime_seconds updates and other tight-loop writers in @Carter3DP's log. Postgres users would see pool exhaustion / "QueuePool limit overflow" instead of file-level lock contention, but the leak shape is identical. (1) get_db now catches BaseException so CancelledError triggers rollback, and wraps both rollback() and close() in asyncio.shield so the cleanup completes even when the await itself is being cancelled by the same cancel scope. The SQLite write lock is released promptly; the connection returns to the pool instead of leaking until GC. (2) A CancelledPoolNoiseFilter (new logging_filters.py filter, attached to sqlalchemy.pool) drops the residual log noise that pre-existing pools still emit during their own cleanup — both the Exception terminating connection ... CancelledError records (matched on prefix + cancellation-driven exc_info, including chained __cause__/__context__) and the symptomatic garbage collector is trying to clean up non-checked-in connection records. Real pool problems — broken connections, network hiccups, exhaustion — keep flowing because they carry a different exception chain or a different message prefix; verified by test_keeps_terminate_with_real_oserror and test_keeps_unrelated_pool_message. 13 new regression tests across test_get_db_cancel_safety.py (commit on clean exit, rollback on regular Exception, rollback on CancelledError — the actual #1112 fix, close runs even if rollback raises, close failure on clean exit doesn't propagate, both rollback + close go through asyncio.shield) and test_cancelled_pool_filter.py (drops cancellation-driven terminate, drops GC-cleanup, keeps real OSError terminate, keeps terminate without exc_info, keeps unrelated pool messages, drops chained-cause CancelledError, defensive guard against self-referential cause chains). Applies to SQLite and PostgreSQL — get_db is dialect-agnostic and the filtered messages come from base sqlalchemy.pool not from any specific dialect.
Windows install: bambuddy.log filling with WinError 10054 — _ProactorBasePipeTransport._call_connection_lost tracebacks (#1113, reported by @cadtoolbox) — Cosmetic-but-noisy. When a printer / MQTT broker / camera RSTs a TCP socket instead of FINing it (offline X1Es in @cadtoolbox's setup, network gear that drops idle TCP, the printer firmware's own watchdog), Windows asyncio's Proactor cleanup path tries socket.shutdown(SHUT_RDWR) on the already-dead socket and hits WinError 10054. Application-layer reconnect logic (paho-mqtt, httpx) handles the actual disconnect fine — paho retries, MQTT comes back, telemetry resumes — so the traceback is pure asyncio bookkeeping noise, but it fired multiple times per minute on @cadtoolbox's 9-printer setup with 5 offline X1Es and was the first thing in the sanitized log. Adds a custom loop.set_exception_handler (new backend/app/core/asyncio_handlers.py) installed on Windows only that pattern-matches the specific _call_connection_lost cleanup-RST signature (three signals together: sys.platform == "win32", the exception is ConnectionResetError, and the asyncio message string contains _call_connection_lost) and downgrades it to DEBUG. Real ConnectionResetErrors raised inside application coroutines (different message string) and other Proactor cleanup errors (BrokenPipeError, ConnectionAbortedError — same callback site, distinct signal worth keeping visible) all pass through to loop.default_exception_handler unchanged. Linux / macOS use the Selector event loop and never hit this codepath, so install_proactor_reset_filter() is an explicit no-op there with a False return — verified by test_install_is_no_op_on_non_windows. 9 unit tests in test_asyncio_handlers.py cover: discriminator matches the exact reported signature, rejects unrelated ConnectionResetErrors, rejects BrokenPipeError even on the same callback site, rejects when no exception object is present, install is platform-gated, install wires the handler onto the loop, suppression doesn't reach the default handler, and unrelated exceptions still hit the default handler. Wired from lifespan startup before any task can spawn that might trip it.
Auto-Print G-code Injection: start snippet landed before printer startup, and {placeholder} substitution was silently broken (#422 follow-up) — Two compounding bugs surfaced by @pleite (Swapmod) and @DevScarabyte (multi-height test prints) on the initial #422 ship: (1) Start snippets were prepended to the entire plate_X.gcode content, which placed them before the printer's bed-heat / homing / nozzle-prime sequence — so a Swapmod start snippet that assumed nozzle-at-temp ran on a cold printer. The injection now anchors at ; MACHINE_START_GCODE_END (the marker sitting at the bottom of every Bambu/Orca slicer's MACHINE_START_GCODE block, after M109 wait-for-temp), matching where a slicer-side custom-start-gcode would land. Files without the marker (older slicer versions) keep the prepend behaviour as a fallback with a warning log. (2) Slicer-style placeholders like G1 Z{max_layer_z} F600 were written verbatim to the output gcode — the printer firmware then parsed Z{max_layer_z} as Z1 and crashed the head into the print on a 60mm-tall model (a real safety issue: prints damaged, top glass + AMS pushed up off the printer when the model was taller than the hard-coded park height). Added a header parser that reads the 3MF's ; HEADER_BLOCK_START..END block (lowercased keys, [units] suffix stripped, spaces → underscores) and a Prusa-style {name} substitution pass that runs over both start and end snippets before injection. Supported placeholders: {max_layer_z} / {max_print_height} (top-layer Z), {total_layer_number} / {total_layers}, {total_filament_weight}, {total_filament_length}, plus any other normalised header key from the source file. Unknown placeholders are left in the snippet verbatim with a warning log — a typo never silently expands to an empty string and the firmware never receives a malformed Z parameter. 16 new regression tests in test_gcode_injection.py covering: start snippet anchored to the marker (printer startup runs first, snippet sits between M109 S220 and the marker, file head untouched), missing-marker fallback path, end snippet still appended at EOF, {max_layer_z} resolved through the alias map, direct-key substitution from the normalised header, unknown-placeholder pass-through, and direct unit tests for each new helper (_parse_3mf_gcode_header, _substitute_placeholders, _inject_start_at_marker). Wiki page documents the supported placeholder list with a safety warning specifically calling out {max_layer_z} for park moves.
Camera page ignored ?fps=N URL parameter (#1131 diagnostic) — CameraPage.tsx hard-coded fps=15 in the stream URL and never read the URL query string, so /camera/1?fps=5 (and similar diagnostic suggestions for the freeze report) were silent no-ops. The sibling StreamOverlayPage already honoured ?fps= correctly; the bug was that CameraPage was the gap. Now reads searchParams.get('fps') via useSearchParams, parses it, falls back to 15 on missing/non-numeric, clamps to the backend's 1–30 range, and threads the resulting value into the stream URL. Backend generate_rtsp_mjpeg_stream already accepted the parameter and re-clamps per-model (chamber-image A1/P1 capped at 5, RTSP capped at 30). 5 new regression tests in CameraPage.test.tsx::fps URL parameter (#1131) cover default-15, honoured value, clamp-above-30, clamp-below-1, and non-numeric fallback — same matrix StreamOverlayPage.test.tsx already pins. Independent of the underlying freeze investigation in #1131; surfaced while triaging that report.
Reprint-from-archive failed with 0500_4003 SD R/W errors after a stuck dispatch, fixable only by restarting the container (#1136) — Reported by @smandon: reprinting from archives sometimes fails immediately with MicroSD R/W exception errors, with the printer's MQTT push referencing a 3MF file from a different unrelated archive (WARIO_Wall_decor_-_NO_AMS.3mf while the user was actually trying to print Cable_Organiser_Cable_Clip.3mf). Once it starts happening, every subsequent reprint hits the same error until the container is restarted. Root cause traced from his support package log to paho-mqtt's client-side QoS 1 queue: when the printer's command channel goes half-broken (telemetry still flowing, publishes silently dropped — same #887/#936 pattern), Bambuddy's 15s dispatch deadline expires (background_dispatch.py:993) and calls force_reconnect_stale_session(). That function was force-closing the underlying socket so paho's auto-reconnect would kick in — but the same mqtt.Client instance, same client_id, and same in-process QoS 1 queue stayed alive across the reconnect. Any unacked publish from the broken session — typically the just-sent project_file for the new archive — got replayed verbatim on the new connection. And because the in-process queue accumulates across multiple stuck dispatches within one Python process, by the second or third stuck reprint there were several stale project_file/resume/stop/clean_print_error commands queued up and replaying together. The printer received the flood, tried to load whichever stale path the firmware latched onto last, found a file that no longer existed on its SD card → 0500_4003. Container restart was the only thing that fixed it because it was the only thing that wiped paho's in-process queue. Replaced the socket-close with a context-aware reconnect: force_reconnect_stale_session() and check_staleness() now go through a routing helper _reset_client_for_reconnect() that picks the right teardown strategy based on caller context. Async-context callers (the dispatch deadline path — background_dispatch.py:993 — which is the actual #1136 trigger, plus FastAPI route handlers via check_staleness) get the hard-reset path: client.disconnect() (broker sees DISCONNECT and drops the session immediately, since clean_session=True), client.loop_stop() (kills the paho network thread, taking its QoS 1 queue with it), nulls out self._client, and calls self.connect() to construct a fresh mqtt.Client with an incremented client_id. New connection starts genuinely empty, no replay possible. Paho-network-thread callers (the developer-mode probe and ams_filament_setting zombie detection inside _update_state, lines ~2604 and ~2623) keep the socket-close fallback — calling loop_stop() from inside the network thread would self-join and deadlock, so the safe pattern there remains "close the socket and let paho's own loop detect it and auto-reconnect on the same client". Theoretical queue replay is still possible on those paths but #1136 specifically traced through the dispatch path, and the legacy socket-close has been battle-tested for the zombie paths since #887. Routing decision is made via asyncio.get_running_loop() — paho's callback thread has no loop, every legitimate hard-reset caller does. 7 regression tests across two new test classes: TestForceReconnectRouting (3 tests pinning the sync-context → socket-close fallback, async-context → hard-reset path with mock-stubbed connect(), and the state-disconnected broadcast firing once on either path) and TestHardResetClientDirect (3 tests pinning the helper directly: old client receives disconnect() + loop_stop(), _client reference cleared, failing disconnect() doesn't propagate so the await chain in background_dispatch.py doesn't break). Existing TestZombieSessionDetection::test_two_timeouts_force_reconnect and TestDeveloperModeProbeTimeout::test_second_timeout_forces_reconnect updated to assert the socket-close path (matching their paho-thread context), preserving the legacy contract. All 2179 backend unit tests pass. Thanks to @smandon for the precise reproduction logs that made this diagnosable from a single support package.
logs/bambuddy.log was silently dropping records from named child loggers — When the trace-ID column was added to the log format (%(trace_id)s), the TraceIDFilter was attached to the root logger. Per Python's logging semantics, a filter on a Logger only fires for records that originate at that logger — records propagated up from child loggers (every backend.app.* module — most of the application) never trigger it. Result: child-logger records arrived at the file handler with no trace_id attribute, the formatter raised KeyError: 'trace_id', and Handler.handleError printed to stderr and dropped the record. bambuddy.log ended up with INFO/DEBUG records appearing only "partially" — exactly the records emitted directly through logging.info(...) (root logger) or uvicorn.access (which had its own explicit filter attachment) made it; everything else was discarded. Moved _trace_id_filter from root_logger.addFilter() to console_handler.addFilter() + file_handler.addFilter() — handler-level filters fire for every record the handler receives, regardless of which logger emitted it. The filter's own docstring already said "Attach to the file handler (or any handler whose format string references %(trace_id)s)" — the implementation was just wrong. New regression test in test_trace.py::TestFilterMustBeAttachedToHandlerNotLogger pins the contract: a child logger emits a record, propagation reaches the handler-level filter, the formatter sees a populated trace_id field, and the line is written. Existing 23 trace tests keep passing unchanged. Restart-shutdown recursion in journalctl was also a side effect — every shutdown log line was raising the formatter ValueError, which got caught and logged… raising again, forever, until the lifespan exit unwound; the new placement breaks the cycle since records now format cleanly.
User-cancelled prints surfaced as "1 problem" on the printer card AND were archived as "Layer shift" failures — Cancelling a print left the printer card stuck on a permanent "1 problem" badge, and stamped the resulting archive entry with failure_reason="Layer shift" — a fake firmware-fault label in the print history. Affects every Bambu printer that emits a cancel-sequence HMS — the user surfaced it on an H2D where the firmware emits both 0300_400C ("The task was canceled.") and the not-in-the-public-wiki 0C00_001B echo as part of the cancel sequence. Four compounding causes, all fixed together. (1) The direct stop endpoint never set the user-stopped flag. POST /printers/{id}/print/stop (backend/app/api/routes/printers.py) sent the MQTT stop command but didn't call mark_printer_stopped_by_user(), so when the printer reported "failed" via MQTT the on_print_complete override (main.py:2558) couldn't reclassify it as "cancelled". The same flag was being set from POST /print-queue/{id}/stop, which is why queue-driven cancels mostly worked but printer-card cancels didn't. The direct endpoint now mirrors the queue path. (2) The HMS → failure_reason heuristic was way too broad. Old code mapped any module 0x0C HMS to "Layer shift" (main.py:3072), but module 0x0C is "Motion Controller" — covers cameras, visual markers, the BirdsEye assembly and the cancel-sequence HMS the firmware emits during a user-cancel. Real layer-shift codes actually live in module 0x03 (0300_4057, 0300_4068, 0300_800C). The same module-only heuristic was also being used to auto-label "Filament runout" (any 0x07) and "Clogged nozzle" (any 0x05), so the same false-positive class existed on those branches. Replaced the broad module heuristic with a curated short-code → reason map (_HMS_FAILURE_REASONS, 23 specific HMS codes from the real wiki); anything not in that map leaves failure_reason=None rather than guessing. Also extracted the logic into a pure function derive_failure_reason(status, hms_errors) so it's unit-testable without the full archive pipeline. (3) Cancel-echo HMS codes were polluting state.hms_errors. Even with (1) and (2) fixed, the printer card kept showing "1 problem" because the firmware kept reporting 0300_400C ("The task was canceled.") in subsequent MQTT pushes — and bambu_mqtt._update_state was happily appending it to state.hms_errors, where the frontend's filterKnownHMSErrors accepted it as a valid known code (it IS in ERROR_DESCRIPTIONS — just describing a user action, not a fault). Added a parse-time filter (_HMS_USER_ACTION_CODES = {"0300_400C", "0500_400E"}) that drops these short codes before they ever enter the state, mirroring the suppression main.py:_HMS_NOTIFICATION_SUPPRESS was already doing for notifications. The card pip, the "X problem" badge, the modal, and any other consumer of hms_errors all get consistent behavior automatically. (4) Frontend counted gcode_state="FAILED" without HMS as a problem. Even with (1)–(3) fixed, the printer card still showed "1 problem" because the H2D's gcode_state sits at FAILED after a cancel until the next print starts, and PrintersPage.tsx:940 (header badge) + classifyPrinterStatus (line 1028) + BulkPrinterToolbar.tsx:102 all unconditionally bumped the error bucket on case 'FAILED'. Real failures attach an HMS error; user-cancels don't — so FAILED-without-HMS now buckets as finished (same operator meaning: print ended, plate may need clearing) and only escalates to error when there's an active known HMS. Same change applied across all three call sites for consistency. 20 regression tests total across three files: test_failure_reason_derivation.py (11 tests pinning the cancel-sequence HMS pair to NOT yield "Layer shift", unknown module-0x0C → None, real layer-shift/runout/clog codes still classify, int-vs-hex code-format tolerance, status="cancelled" symmetric with "aborted"), test_bambu_mqtt.py::TestHMSUserActionFiltering (4 tests pinning 0300_400C/0500_400E filtering on both hms[] and print_error parse paths, real layer-shift 0300_4057 still passes through, mid-cancel concurrent real-fault keeps the real one and drops only the echo), and PrintersPageBucketing.test.ts (5 tests pinning FAILED-without-HMS → finished, FAILED-with-known-HMS → error, FAILED-with-only-unknown-HMS → finished, FINISH baseline unchanged, disconnected stays offline). Existing stale state on running printers clears on the next MQTT push that includes an hms key (printer firmware re-sends the list, parser filters it out, badge clears). Users with a stuck badge can also click the HMS modal "Clear" button to clear immediately via MQTT command.
Settings → API Keys: deleted key stayed on screen until manual reload — the delete-key mutation marked the ['api-keys'] query stale via queryClient.invalidateQueries, which in v5 should also refetch active queries — but in practice the deleted row remained visible until the user reloaded the page. Switched the mutation's onSuccess to queryClient.setQueryData so the deleted key is filtered out of the cache synchronously the moment the API confirms; no refetch round-trip required, no chance for an invalidation→refetch race to leave the UI stale. Create-path keeps invalidateQueries since that one was working correctly. New SettingsPage.test.tsx test "removes a deleted key from the list without a page reload" pins the synchronous-removal contract.
SpoolBuddy AMS page: re-assigning a just-unassigned spool sometimes showed an empty picker (#1133 follow-up) — Reported live during the rollout of the #1133 picker change: unassigning a Bambu PLA Metal spool from SpoolBuddy and re-opening the picker showed "no spools available" — the just-freed spool was missing. The investigation surfaced four distinct causes that all needed addressing for the picker to stay correct, plus a deployment-side cause that prevented any of the fixes from reaching the live kiosk. (1) Dual cache-key shapes for spool assignments: SpoolBuddyAmsPage keys by ['spool-assignments', selectedPrinterId] while the shared AssignSpoolModal keys by ['spool-assignments'], and SpoolBuddyAmsPage.unassignMutation.onSuccess only invalidated the printerId-keyed one, leaving the modal's unkeyed cache stale. Both invalidate calls (mutation success + modal-close handler) now hit both keys; collapsing the two key shapes into one is intentionally deferred since the dual-key pattern predates this change and shows up in 6 components. (2) Toggle wasn't a real escape hatch: the existing "Show all spools" toggle's label said it would help when a spool was hidden but only bypassed the material/profile filter, not the assignment-elsewhere gate. It now bypasses BOTH filters, making it a real escape hatch (the backend's assign_spool is upsert-per-(printer/ams/tray), so picking a currently-taken spool just creates a second assignment row — foot-gun for normal flows but exactly the recovery path this toggle is for). (3) Cross-component cache pollution: ['inventory-spools'] was used as a query key by 5+ components calling getSpools() with different includeArchived arguments — React Query treated them as one query and served whichever response landed first, so a SpoolBuddy component priming the cache with getSpools(false) could hide spools from the modal that wasn't yet present at that fetch time. The modal now uses its own dedicated key ['inventory-spools', 'assign-modal'] + getSpools(true) so it's never at the mercy of someone else's cache state. (4) Empty-state had no diagnostic surface: when the picker showed "No spools available" there was no way to tell why — was the fetch empty? Were spools archived? All assigned elsewhere? A small counter X fetched · Y archived · Z assigned to other slots now renders in the empty state so future reports of this kind are immediately answerable from a screenshot rather than requiring devtools digging. (5) Browser holding stale JS forever: index.html was being served without Cache-Control headers, so Chromium's heuristic-cache freshness window kept the OLD HTML "fresh" for days across browser restarts. The OLD HTML referenced an OLD content-hashed bundle, which was also still in disk cache, so the kiosk kept running pre-deploy JS no matter how many times its Chromium was restarted or cache-cleared — the persistent profile would re-seed the cache from disk on next start. Backend now sends Cache-Control: no-cache, must-revalidate on both / and the SPA catch-all that serve index.html; service worker CACHE_NAME bumped from bambuddy-v25 to bambuddy-v26 so any client that does eventually re-fetch sw.js invalidates its CacheStorage; and spoolbuddy/install/install.sh now generates the kiosk launcher with --user-data-dir=/tmp/spoolbuddy-kiosk-userdata plus a pre-launch rm -rf so every kiosk restart starts from a clean slate (the kiosk has no per-user state worth persisting — auth token is in the URL query, not a stored cookie). 6 net-new tests across AssignSpoolModal.test.tsx (toggle escape-hatch behavior) and tests/integration/test_static_html_cache_headers.py (Cache-Control directive on root + SPA catch-all routes, no leak onto API routes). Reproduced end-to-end on an H2D + dual AMS + SpoolBuddy display: unassign Bambu PLA Metal Iridium Gold Metallic from slot B4 → reopen picker → spool now visible without browser intervention.
Plate-clear button stayed visible after the API cleared awaiting_plate_clear outside the printer-card click path (#1128) — awaiting_plate_clear is a Bambuddy-side flag, not a printer-side one, so toggling it does not produce an MQTT push from the printer. Commit 4e86e8c added the flag to the printer_status payload so MQTT-driven broadcasts (e.g. when a print finishes and on_print_complete sets the flag to True alongside a state transition to FINISH) carry it correctly. The reverse transition didn't get the same treatment: POST /printers/{id}/clear-plate mutated PrinterManager._awaiting_plate_clear and persisted to the DB, but emitted no printer_status WebSocket update — and the in-main.py status-change broadcaster's status_key deduplication intentionally excludes Bambuddy-side flags, so even a coincidentally-arriving MQTT push wouldn't reflect the change. The "Mark plate as cleared" button on the printer card disappeared "immediately" after a click only because the React Query cache was being optimistically updated client-side; clearing the flag through any other route (an admin script, a second tab, an automation hitting the endpoint directly, the scheduler at print_scheduler.py:1844 when dispatching the next queued print) silently left every UI subscriber but the originating tab stale until a coincidental status refresh. Centralised the broadcast in PrinterManager.set_awaiting_plate_clear itself rather than at each call site, so every current AND future caller is covered without remembering to wire it up: a new _broadcast_status_change(printer_id) private coroutine is scheduled alongside the existing _persist_awaiting_plate_clear whenever the flag flips under a running event loop. The broadcast lazy-imports ws_manager to keep printer_manager.py clean of application-layer infra at module-import time, short-circuits when get_status returns None (printer disconnected — the next reconnect produces a fresh push anyway), and swallows ws_manager.send_printer_status failures so the persistence path can complete even if the WS layer is temporarily unavailable. The same hook is now in place for any other Bambuddy-side flag that gets added to printer_state_to_dict later — they'll all need to broadcast their own changes for the same reason. 8 new regression tests in test_printer_manager_status_broadcast.py: schedules-on-True/False/loop-running/no-loop/loop-stopped contracts, _broadcast_status_change happy path with payload assertion, skip-when-no-state, swallow-WS-errors, and an end-to-end live-loop test that fires set_awaiting_plate_clear(False) and asserts a broadcast lands with awaiting_plate_clear: false in the payload. Existing 24 tests in test_scheduler_clear_plate.py continue to pass unchanged because they instantiate PrinterManager() without attaching a loop (sync unit-test path) — the new _schedule_async call short-circuits on the same loop check the existing persistence call already used. Thanks to @EdwardChamberlain for the precise root-cause analysis (down to the exact line and the suggested ws_manager.send_printer_status() fix).
Uvicorn HTTP access log was missing from bambuddy.log, leaving rogue server-state changes untraceable — When an HTTP endpoint that mutates server state fires unexpectedly (the canonical example: a print spontaneously stopping mid-job because something hit POST /printers/{id}/print/stop), the only on-disk trail was Bambuddy's own application log — which by design only records the outbound MQTT publish (Sent stop print command), not the inbound HTTP call that triggered it. The result was an unsolvable mystery on 2026-04-26: prints stopping with no preceding Bambuddy-side log line, no way to identify the caller, and the rotated container stdout already gone by the time the support pack was generated. Root cause: uvicorn ships its access logger with propagate=False by default, so the existing RotatingFileHandler attached to root never received those records. main.py now attaches the same file handler directly to logging.getLogger("uvicorn.access") and applies a new WriteRequestsOnlyFilter (backend/app/core/logging_filters.py) that keeps POST / PUT / PATCH / DELETE and drops GET / HEAD / OPTIONS. Status polls, camera streams, snapshot fetches, websocket upgrades, and CORS preflights account for the bulk of access traffic on a running install and none of them can change server state on their own — dropping them keeps bambuddy.log focused on lines that matter for incident triage without churning the 5 MB rotation window faster than it's useful. Filter anchors on the "+verb+ pattern uvicorn's format string guarantees, so a literal "POST" substring inside a URL (e.g. GET /api/posts/POST_123) cannot false-match. The filter lives in its own module so the test suite can import it without pulling in main.py's entire startup graph. 13 new tests in test_logging_filters.py cover all four write verbs being kept, GET/HEAD/OPTIONS being dropped, two URL-contains-verb-substring false-match guards, empty/unrelated-line/idempotency edge cases. Output now looks like 2026-04-26 09:23:14,690 INFO [uvicorn.access] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200 — one grep "POST.*stop" away from "who triggered this".
Spool auto-assign hit IntegrityError on Postgres when AMS pushes arrived in quick succession — Bambu MQTT can deliver two ams_data push frames for the same printer ~30 ms apart (observed on H2D + dual AMS at K-profile-load / RFID-read boundaries). Each frame triggers on_ams_change in backend/app/main.py, whose auto-assign block reads (printer_id, ams_id, tray_id), decides "no existing assignment", and INSERTs via auto_assign_spool — and the two callbacks raced in their respective sessions, both deciding to insert, with the second commit losing on spool_assignment_printer_id_ams_id_tray_id_key. SQLite's WAL serial-write semantics had been silently swallowing the race for ~7 weeks since the spool-assignment feature shipped (latent in ec82092b); when optional Postgres support landed in 610431d6 and asyncpg started allowing true concurrent transactions, it surfaced as WARNING [main] RFID spool auto-assign failed: ... duplicate key value violates unique constraint ...; DETAIL: Key (printer_id, ams_id, tray_id)=(1, 0, 0) already exists. Added a per-printer asyncio.Lock (_ams_assignment_locks keyed by printer_id) wrapping the auto-assign critical section so two callbacks for the same printer serialise — by the time the second one's session runs select(SpoolAssignment).where(...), the first's commit is visible and the early-return "existing assignment" branch fires instead of a duplicate INSERT. The Spoolman sync block further down in the same callback intentionally stays OUTSIDE the lock — it's network-bound and idempotent, so serialising it would block subsequent AMS callbacks for the duration of a remote roundtrip. Per-printer scope keeps unrelated printers fully parallel: one printer's slow assignment never blocks another's. The auto-unlink block above the assign block isn't wrapped because its DELETE/UPDATE operations don't have the same constraint surface; the assign-block lock is sufficient because the second callback's select will see the first's committed state. 5 new regression tests in test_ams_assignment_lock.py cover same-printer-same-lock identity, different-printers-different-lock isolation, second acquirer waits for first inside the lock (proves serialisation), different printers run truly in parallel under a held lock (proves per-printer scope), and an auto-cleanup fixture resets the module-level dict between tests so cross-test loop affinity bugs can't surface.
Camera TLS proxy logged "Unhandled exception in client_connected_cb" when ffmpeg dropped its half of the connection mid-stream under uvloop — The bidirectional forwarders inside services/camera.py::create_tls_proxy._handle (the OpenSSL TLS shim added in #661 so Bambu's RTSPS handshake works around Debian GnuTLS hardening) caught (ConnectionError, OSError, asyncio.CancelledError) on writes, but uvloop's UVStream.write raises a plain RuntimeError from UVHandle._ensure_alive when the underlying handle is already closed. asyncio's default selector loop reports the same situation as ConnectionResetError, so the bug only surfaced on uvloop deployments — and only at the moment the client (typically ffmpeg or a snapshot-capture subprocess) tore down its socket while the proxy was mid-flush. The RuntimeError slipped past the except tuple, escaped the forwarder coroutine, and asyncio's client_connected_cb task-exception handler logged a noisy multi-line traceback ending in RuntimeError: unable to perform operation on <TCPTransport closed=True ...>; the handler is closed. Added RuntimeError to the except tuple in both _fwd_to_server and _fwd_to_client (the latter being the actual frame in the bug report — server→client is where buffered TLS chunks land after the client has gone). The forwarders are intentionally fire-and-forget on tear-down; once either peer drops, both halves of the proxy should exit quietly and the existing dst.close() in the finally block already handles cleanup. No functional regression possible — the connection is already dead by the time the exception fires; this only changes whether asyncio logs an "Unhandled exception" trace for it. 2 new regression contract tests in test_camera_tls_proxy.py use inspect.getsource to assert both forwarder closures' except clauses include RuntimeError, since the closures are nested inside _handle and extracting them just for testability would require a pure-cosmetic refactor of the proxy.
Background-dispatch reported "Print started successfully" when the printer never actually transitioned (#1134, follow-up to #1042) — The int32 task_id modulo fix that was the original root cause of #1042 is verified working in the reporter's most recent support pack (the published task_id values are well below 2^31-1 and match the int(time.time() * 1000) % 2_147_483_647 formula exactly). The remaining residual — "the UI reports despatch success which is slightly misleading" — was a real second bug class: the post-dispatch watchdog _verify_print_response in services/background_dispatch.py was fire-and-forget. It would correctly detect that the printer never transitioned (e.g. P1S sitting in gcode_state: FAILED with HMS 0300_400C "task was canceled", a half-broken MQTT session, an SD card error, or any other pre-print blocker), log a did not respond to print command within 15s warning, force-reconnect the MQTT session — and then return without touching the dispatch job state. The dispatch job had already been marked successful on the optimistic MQTT-publish-acknowledged path, so the UI carried on showing "Print started successfully" while the printer sat idle. The watchdog now returns a bool and is awaited inline by both call sites (_run_reprint_archive at line 687, _run_print_library_file at line 860); on False (timeout) the call sites raise a RuntimeError carrying a user-actionable message ("Printer did not acknowledge print command — state still {pre_state}. Check the printer for a pending error (HMS code, plate-clear prompt, SD card) and try again."), which routes through the existing _mark_job_finished(failed=True, …) path so the dispatch UI shows a real failure toast and the library-file flow's freshly-created archive is db.rollback()'d (no orphan rows for prints that never started). The watchdog now also accepts subtask_id advancing past the captured pre_subtask_id as a definitive "command landed" signal — same as the queue-side watchdog at print_scheduler.py:1992 (#1078) — so slow H2D FINISH→PREPARE transitions (~50 s observed) don't false-fail when the printer has clearly accepted the project_file but is still in FINISH. Default timeout raised from 15 s to 90 s to match the queue-side watchdog (#967 / #1078) and give the same headroom on both dispatch paths. Brief mid-window MQTT disconnects (get_status() is None for one tick) now keep polling instead of immediately failing — matches what the queue watchdog already does and avoids false-failing on transient telemetry gaps. The existing force_reconnect_stale_session recovery is preserved on the timeout path. 8 new regression tests in test_background_dispatch_watchdog.py cover state-change pickup, subtask_id-change pickup with state still FINISH (the H2D case), neither-signal-changed timeout + force-reconnect, pre_subtask_id=None backwards-compat, post-dispatch subtask_id=None not counting as a change (avoids false-pass on transient reconnect), brief disconnect not short-circuiting the window, persistent disconnect for the full window returning False, and a contract test that the default timeout is 90 s. Thanks to @EdwardChamberlain for the detailed retest with logs that pinpointed the watchdog's no-propagation gap.
Bambu RFID auto-match created duplicate inventory rows for Quick-Add and non-Bambu-branded spools (#918) — find_matching_untagged_spool is supposed to attach a Bambu RFID UID to a pre-existing manually-logged spool of the same material/color so users who log inventory before scanning don't end up with a duplicate row on first AMS read. Two bugs in the matcher meant it almost never worked for the actual reporting workflow: (1) the subtype filter was strict — when the AMS tray reports tray_sub_brands="PLA Basic" the matcher required Spool.subtype = 'Basic' exactly, so any Quick-Add row (Quick-Add only requires material, leaving subtype=NULL) was excluded and duplicated on first AMS read. (2) the docstring claimed it filtered on brand but the WHERE clause didn't, so a same-color Polymaker untagged spool would silently acquire a Bambu Lab tray UUID, leaving the user with brand="Polymaker" but a Bambu UUID — silent data corruption. Both bugs are addressed in the same query: subtype now prefers an exact match but accepts a NULL-subtype row as fallback (with a CASE in ORDER BY so an exact match still wins when both exist), and brand is now restricted to "contains 'bambu' (case-insensitive)" or NULL — matching 'Bambu' (the form's DEFAULT_BRANDS value), 'Bambu Lab' (the catalog value), 'BambuLab', 'bambu lab', etc., while rejecting any explicitly-named third-party brand. 6 new regression tests in test_spool_tag_matcher.py cover the NULL-subtype fallback, exact-subtype-wins-over-NULL ordering, non-Bambu brand rejection, NULL brand acceptance, all four Bambu brand spelling variants, and the full Quick-Add scenario (brand=NULL + subtype=NULL). The broader UI proposals in #918 (manual override / merge / disambiguation prompt) are intentionally out of scope — once the matcher works, the duplicate-on-RFID complaint that motivated those proposals goes away. Thanks to @ViridityCorn for the report and pointing at the right function, and to @Arn0uDz for confirming with a 20-spool repro.
Swagger UI link in Settings → API Keys rendered a blank page — the global CSP applied by security_headers_middleware set script-src 'self' and style-src 'self' 'unsafe-inline' https://fonts.googleapis.com, which blocked both the inline <script> that boots Swagger and the cdn.jsdelivr.net URL that ships swagger-ui-bundle.js / swagger-ui.css. FastAPI's /docs page therefore loaded a 1 KB shell with no JS executed, leaving an empty white page. The middleware now emits a docs-scoped CSP for /docs, /redoc, and /docs/oauth2-redirect that allows https://cdn.jsdelivr.net for scripts + styles, the FastAPI/Redoc favicon hosts for images, and 'unsafe-inline' for the Swagger boot script — every other route keeps the unchanged stricter SPA policy.
Camera stream second viewer fails / kicks the first off (#1089) — Most Bambu Lab printers only allow one concurrent camera connection (RTSP socket on X1/H2/P2, port-6000 chamber-image socket on A1/P1), but GET /printers/{id}/camera/stream opened a fresh upstream per viewer keyed on a per-request stream_id. Two browser tabs / two dashboard cards → the second viewer either failed silently or kicked the first one off. New services/camera_fanout.py::MjpegBroadcaster owns a single upstream per printer and fans pre-formatted MJPEG chunks out to N subscriber queues; new viewers tap the existing connection. When the last subscriber leaves, the upstream stays alive for a 5 s grace window so a tab refresh or "open in new tab" doesn't pay an ffmpeg/RTSP reconnect, then tears down cleanly. Per-subscriber queues are bounded (depth 4) so a slow viewer drops frames for itself rather than blocking the broadcaster — live video, old frames have no value. Stop endpoint and app-shutdown both call into the broadcaster's force-shutdown path so subscribers wake up via an upstream-gone sentinel instead of hanging on queue.get(). External-camera path is unchanged (user-supplied MJPEG/RTSP servers handle multi-viewer themselves). The upstream uses a deterministic {printer_id}-fanout stream id so every existing prefix-match in cleanup_orphaned_streams, camera_status, the snapshot fall-through in main.py, and the stop endpoint continues to find it without changes. Two follow-up correctness fixes from the audit pass: (1) _stream_start_times[printer_id] is now set with setdefault() so /camera/status reports the SHARED upstream's age — previously each new viewer overwrote it, making stream_uptime jump backward whenever a second viewer attached; (2) the route now retries subscribe() once on RuntimeError to close a tiny race where the grace teardown can flip the broadcaster to stopped between the registry lookup and the subscribe call (the retry forces the registry to mint a fresh broadcaster). Detach log line shows the post-unsubscribe count returned atomically by unsubscribe() — no more two viewers leaving simultaneously both reporting subscribers=0. Permission gates unchanged: /camera/stream still requires the existing token (minted by POST /camera/stream-token with CAMERA_VIEW); /camera/stop still requires CAMERA_VIEW; the broadcaster is internal infra with no FastAPI surface. 13 unit tests for the broadcaster (single subscriber, multi-subscriber-shares-one-pump, slow-subscriber-doesn't-block-fast, grace-window teardown, grace-cancelled-on-rejoin, force-shutdown sentinel, iter_subscriber exits on upstream-gone and on client-disconnect, registry replaces stopped broadcasters, subscribe() raises on stopped broadcaster, unsubscribe() returns post-removal count atomically across concurrent leavers, double-unsubscribe is idempotent, and the route's force-shutdown-then-fresh-subscribe retry path) plus 2 new integration tests on the stop endpoint covering the deterministic fan-out stream id and the shutdown_broadcaster wiring. Thanks to @swheettaos for the diagnosis and broadcaster sketch.
Uploads to writable external folders silently landed in internal storage (#1112) — LibraryFolder has an external_readonly flag, so the model already distinguishes writable from read-only external mounts, but POST /library/files rejected only the read-only branch and then unconditionally wrote to get_library_files_dir() with a UUID-scoped filename. The resulting LibraryFile row linked back to the external folder via folder_id, so the file showed up in the Bambuddy UI and could be printed, but the bytes physically lived in archive/library/files/ and never touched the mount — invisible from any other machine accessing the same NAS/SMB share. New _resolve_upload_destination() helper detects writable external targets and writes through to <external_path>/<filename> (keeping the original filename so the file is recognisable on the mount), with guards for missing/inaccessible path (400), non-writable mount (400), pre-existing filename on the mount (409 — no silent overwrite; the user is expected to rename and retry, matching how scan treats external files as externally-owned bytes), and a resolve + relative_to path-traversal guard on the joined destination. DB row now matches what scan produces: is_external=True, file_path=<absolute external path>, so the existing download / delete / dedupe paths work unchanged (to_absolute_path already fast-paths is_absolute() inputs, and external-file deletion already bypasses trash and only drops the DB row + internal thumbnail). POST /library/files/extract-zip is now rejected against any external folder (not just read-only) with a clear "extract the ZIP on the external mount and run Scan" message — the nested-subfolder creation path would need to mkdir on the mount and create matching is_external=True LibraryFolder rows, which is a separate design round, and the Scan flow already handles that shape. 7 new integration tests cover: bytes land on the mount; DB row has is_external=True + absolute file_path; filename collision → 409 with prior bytes preserved; vanished external path → 400; path-traversal filename never escapes the external dir; extract-zip into writable external rejected with the Scan hint; root uploads unchanged.
Queue item stuck at "printing" when print failed before reaching RUNNING (#1111) — Dispatching a file sliced for the wrong nozzle size (or any other pre-print error: AMS fault, wrong plate, nozzle not installed, etc.) left the queue item stuck at status="printing" forever, blocking every subsequent pending item for that printer (check_queue seeds busy_printers from any row in 'printing' state and skips further dispatches for those printer IDs). Completion detection in BambuMQTTClient._process_message required the print to have reached RUNNING — either via _previous_gcode_state == "RUNNING" or the _was_running fallback — but a nozzle-mismatch failure transitions the printer IDLE → PREPARE → FAILED without ever entering RUNNING, so neither branch matched and on_print_complete never fired. The diagnostic log line at bambu_mqtt.py:2690 ("State is FAILED but completion NOT triggered: prev=PREPARE, was_running=False") confirmed the path. Completion now also fires on FAILED from a pre-print state (PREPARE or SLICING) — restricted to those two so a stale FAILED on first connection (prev=None) still can't accidentally advance an unrelated queue item. Additionally, when a queue item transitions to failed the handler in main.py now populates error_message from the printer's current HMS error list, rendered via the existing backend/app/services/hms_errors.py lookup table (e.g. [0500_4038] The nozzle diameter in sliced file is not consistent with the current nozzle setting. This file can't be printed.) — previously error_message was left NULL, so users saw "failed" with no hint at the cause. 5 new unit tests in TestPrePrintFailureCompletion cover PREPARE→FAILED and SLICING→FAILED firing, IDLE→FAILED and initial-FAILED not firing (boot-time safety), and HMS errors being passed through in the callback payload; 6 new tests in test_hms_error_summary.py cover the error-message formatter (known-code lookup, unknown-code fallback, multi-error join, malformed-entry tolerance, all-malformed → None, empty → None). Thanks to @MartinNYHC for the report.
Tailscale cert-renewal restart silently failed mid-way (follow-up to #1070) — The daily renewal path creates an asyncio.Task to restart VP services with the new cert. Inside that task, stop_server() / stop_proxy() call _cancel_restart_task(), which cancelled+awaited the currently-running task (itself). The self-await raised RuntimeError, got caught by the broad exception handler, but the cancel flag was still set — so the next await in stop_server raised CancelledError and aborted the restart partway through. The VP kept running the OLD expired cert until the process was manually restarted, silently defeating the feature. _cancel_restart_task now checks asyncio.current_task() and skips the cancel+await when the caller IS the restart task itself. Two new regression tests cover the self-cancel and outside-cancel paths.
Settings table filled with duplicate rows on legacy SQLite installs — pre-UNIQUE-constraint databases stored the settings.key column without a unique index, so the seed loop's INSERT OR IGNORE silently degraded to a plain INSERT and every systemctl restart bambuddy added another row of advanced_auth_enabled / smtp_auth_enabled. After a handful of restarts, scalar_one_or_none() in is_advanced_auth_enabled and similar sites blew up with MultipleResultsFound, 500'ing the login flow. run_migrations now dedupes (keeps MIN(id) per key) and creates the missing ix_settings_key unique index before the seed loop runs. Postgres installs were unaffected. 4 new regression tests cover legacy-with-dupes, legacy-already-clean (idempotent), and fresh-install (no-op) paths.
Virtual printer card's Tailscale FQDN copy button failed on HTTP — navigator.clipboard.writeText is only available in secure contexts (HTTPS / localhost). When Bambuddy is reached over plain HTTP via a LAN or Tailscale IP, the clipboard API is blocked and the copy button silently failed with a generic "Failed to update settings" toast. Added a legacy document.execCommand('copy') fallback via a hidden textarea for non-secure contexts; the textarea is removed in a finally block so it doesn't leak into the DOM on exception paths. New virtualPrinter.toast.copyFailed i18n key across all 8 locales for the rare case where both paths fail.
Install script failed for first-time users — three separate permission issues in install/install.sh stopped the native installer mid-way: (a) download_bambuddy chowned the empty install dir to the service user BEFORE running git clone as the current user → permission denied on .git; (b) setup_virtualenv created the venv as the service user but then ran pip install --upgrade pip as the current user → permission denied writing venv/bin/pip; (c) build_frontend would have hit the same pattern on npm ci. All three now route through sudo -u "$SERVICE_USER" (or sudo -H -u for npm so HOME is set correctly for the npm cache). The git-clone fix runs as root then chowns the tree. macOS path unchanged (no service user there).
H2C dual-nozzle detection missed post-2026 serial batches (#1105) — Bambu has started shipping H2C units with a new serial prefix (31B8B… observed on a January 2026 unit) instead of the legacy 094… shared by the H2D/H2C/H2S family. The K-profile edit flow (backend/app/api/routes/kprofiles.py) and the delete-K-profile MQTT path (backend/app/services/bambu_mqtt.py::delete_kprofile) branch on serial prefix to pick the dual-nozzle command format, so units with the new prefix were silently falling into the single-nozzle branch and getting the wrong K-profile payload shape. Added 31B8B (5-char match covering the model code + revision bytes, leaving the revision-letter slot free to iterate) alongside the existing 094 and 20P9 prefixes; runtime paths that auto-detect dual-nozzle from device.extruder.info were already prefix-agnostic. New regression test test_h2c_new_prefix_uses_dual_nozzle_format in test_bambu_mqtt.py. Thanks to @m4rtini2 for the report.
Spoolman iframe silently blank on HTTPS Bambuddy with HTTP Spoolman (#1096) — Users behind an HTTPS reverse proxy (Traefik / Nginx / Caddy) pointing the Spoolman URL at plain HTTP saw the Filament tab render as a blank page with only a console-side Mixed Content warning. CSP was fine (the #1054 fix already allowed frame-src http:), but browsers enforce mixed-content blocking independently of CSP — an HTTP iframe inside an HTTPS parent is always blocked. Bambuddy can't technically fix this (the browser is correct to refuse), so instead of the silent blank frame the Filament page now detects the protocol mismatch (window.location.protocol === 'https:' plus Spoolman URL starting with http://) and renders an inline warning card explaining the root cause, pointing users at the right fix (put Spoolman behind the same HTTPS reverse proxy and update the Spoolman URL in Settings), and offering an "Open Spoolman in a new tab" button as an immediate workaround — a standalone tab isn't subject to mixed-content rules. Localised across all 8 UI languages. Thanks to @jsapede for the report.
Reprint-from-Archive left created_by_id as NULL (#730 follow-up) — 0.2.4b1 fixed user attribution for Direct Print / File Manager / Library prints, but the reprint path was still unattributed on the archive row. Reprint intentionally reuses the source archive (to avoid duplicate rows — see register_expected_print), so an archive auto-created from a printer-initiated print with no known user stayed created_by_id=NULL forever, even after multiple reprints by authenticated Bambuddy users. Print Log got the reprinter's username correctly (via _print_user_info), but the Statistics per-user filter — which reads archive.created_by_id — kept showing the archive as unassigned. Fix in main.py's print-complete handler: when the archive has no created_by_id and a print-session user is set (which reprint always sets via set_current_print_user), back-fill the archive's attribution. Never overwrites an existing attribution — the original uploader keeps ownership; NULL archives are the only ones touched. Thanks to @3823u44238 for the detailed retest that caught this.
Settings: failed-save toast looped forever when the user lacked settings:update — the Settings page runs a debounced auto-save effect that fires PATCH /settings whenever localSettings diverges from the last server snapshot. When a delegated user with settings:read but not settings:update toggled a control, the effect fired PATCH, got 403, and kept re-firing every ~500 ms producing an endless stream of identical "Failed to save" toasts. Gated at three points so the mutation is never attempted without permission: (1) the updateSetting callback — every onChange path — shows one settings.toast.noPermissionUpdate toast and short-circuits before diverging localSettings; (2) the debounced-save effect safety-nets the same check in case any call site bypassed updateSetting; (3) the language <select> was a fire-and-forget direct api.updateSettings call that always flashed a success toast regardless of outcome — it now goes through updateMutation with the same permission guard. New settings.toast.noPermissionUpdate key added across all 8 locales with full translations (not English-fallback).
Groups: edits to custom-group permissions appeared lost on reopen (#1083) — creating a custom group and reopening the editor showed the correct permissions, but after editing that group's permissions and saving, reopening the editor within ~1 minute displayed the pre-edit snapshot as if the save had failed. The backend PATCH /api/v1/groups/{id} was persisting correctly (now covered by four new integration tests in test_groups_api.py, including a direct DB read after update); the issue was purely in the frontend React Query cache — GroupEditPage.onSuccess invalidated ['groups'] (the list) but left the ['group', id] detail cache stale, and with the app-wide 60 s staleTime the next mount served the cached pre-update body instead of refetching. onSuccess now primes the ['group', id] detail cache with the PATCH response body so the next mount hits fresh data immediately without a round-trip. Create-path invalidates ['group'] for symmetry. Regression test in GroupEditPage.test.tsx verifies the detail cache contains the updated permissions after save.
Setup: re-enabling auth could 422 on a password the form no longer needs — after disabling authentication and re-enabling it (common when switching between local auth and LDAP, or recovering from a bad config), the setup form still sends admin_password in the body even though the backend route ignores it when an admin user already exists. The SetupRequest Pydantic schema enforced password complexity (uppercase + lowercase + digit + special char) unconditionally, so any existing password that predated the complexity rule — or a legitimate LDAP-mode placeholder — triggered 422 Value error, Password must contain at least one special character before the route body could decide to ignore the field. Complexity validation has moved out of the schema and into the route body, scoped to the branch that actually creates a new local admin. Re-enabling auth with an existing admin (or any LDAP user) now accepts whatever the form sends; fresh first-time setup still rejects weak passwords with a clear 400. Two regression tests added in test_auth_api.py: weak password rejected at setup when creating the first admin, weak/placeholder password accepted when an admin already exists.
Queue: batch (quantity>1) double-dispatched onto the same printer — scheduling an ASAP print with quantity > 1 could end up with two queue items in 'printing' status for the same printer, surfaced in the logs as BUG: Multiple queue items in 'printing' status for printer N. The scheduler's in-memory busy_printers set was seeded empty each tick and only populated after _start_print succeeded in the current iteration, so on the next tick (30 s later) _is_printer_idle() read the printer's live MQTT state — which on H2D / P1 series lags several seconds behind the print command and still reported IDLE / FINISH — and dispatched the second batch item onto the already-running printer. check_queue() now queries PrintQueueItem for status='printing' rows and seeds busy_printers with their printer IDs before iterating pending items, so any printer with an outstanding dispatched job is excluded regardless of what MQTT currently reports. Regression covered in test_phantom_print_hardening.py (TestBusyPrinterSeedingFromPrintingItems): seeding query returns printers with 'printing' rows only, returns empty when none exist, and end-to-end check_queue() does not call _start_print for a pending item whose printer already has a 'printing' row even when _is_printer_idle() is forced True.
Queue: active-item progress bar flashed 100% before dropping to 0% — immediately after a queue item was dispatched, the per-item progress bar on the Queue page showed 100% (or whatever the prior print's final mc_percent was) for the few seconds between dispatch and the printer's MQTT state transitioning to RUNNING. Frontend QueuePage.tsx read status.progress directly from the printer's live MQTT snapshot, which carries over the last reported value from the previous print until the new one starts ticking. The progress bar, remaining time, ETA, and layer counter are now gated on status.state being RUNNING or PAUSE; in any other state (including FINISH from the prior print, IDLE, or PREPARE while heating) the bar renders at 0% with no stale ETA/layer values.
i18n placeholder mismatches in Japanese rendered literal {{count}} / {{name}} strings in the UI — 27 ja strings had drifted from the en placeholder names: printers.activeNozzle used {{side}} while the runtime passes nozzle; archives.card.layers / queue.addedBy / maintenance.days / groups.form.permissions and 22 others either lost their placeholders entirely (translator dropped them) or used renamed keys ({{count}} → {{username}}). i18next can't bind a placeholder it doesn't see, so the count would silently disappear or — for {{count}} keys — render the raw {{count}} token in the UI. Backfilled all 27 to match en's placeholder set so interpolation resolves; the parity check now reports zero placeholder mismatches across all 8 locales. Same pass also fixed one fr mismatch (projects.noProjectsFilteredHelp lost {{status}}).
"Open in Slicer" fails on Windows / Linux for any filename containing spaces or special characters (#1059) — clicking "Open in Slicer" from the File Manager or Archives page produced one of three symptoms depending on the file: .3mf files opened Bambu Studio / OrcaSlicer but the app showed "Importing to Bambu Studio failed. Please download the file and open it manually" (the file on disk was 0 bytes); .stl files greyed the button out; .step couldn't be previewed at all. The protocol-handler URL emitted by frontend/src/utils/slicer.ts for OrcaSlicer (orcaslicer://open?file=<URL>) and Windows/Linux Bambu Studio (bambustudio://open?file=<URL>) was built by plain string concatenation with no encodeURIComponent() — the macOS bambustudioopen://<URL> branch was already encoding correctly, which is why macOS users didn't see this. A stale comment block in the file claimed the browser preserves the URL in the query string so no encoding is needed; that's true for the browser-to-OS handoff but ignores that the slicer itself calls url_decode() on the received query (BS post_init() calls url_decode then split_str; OrcaSlicer's Downloader regex-extracts then url_decode). Any already-percent-encoded character in the download URL — most commonly %20 from filenames with spaces, which Bambuddy's archive paths produce naturally — decoded to a literal space and the slicer's subsequent HTTP GET came back 0 bytes or 404. All three URL forms now encodeURIComponent() the file URL, so the slicer sees the correctly-encoded URL after its own url_decode. The comment block is corrected to document the actual invariant. Regression test in slicer.test.ts feeds the exact issue reproduction URL (Toothpick%20Launcher%20Print-in-Place.3mf) and asserts %2520 appears in the generated orcaslicer:// href — so any future refactor that drops the encoding fails CI. Thanks to @jsapede for the double-encoding diagnosis and @AllanonBrooks and @lunaticds for the original reports.
</style> sequence in its CSS Stringify output. The caret range in frontend/package.json already accepted 8.5.12, so this is a lockfile-only bump; vite, autoprefixer, and @tailwindcss/postcss all dedupe onto the same 8.5.12 with no nested copies left in node_modules. PostCSS runs at build time only and Bambuddy doesn't pass user-controlled CSS through it at runtime, so the practical impact even on the older version was nil — this is hygiene + clearing the npm audit warning..gcode files (Bambuddy stores .gcode.3mf), the printer selector wasn't useful when the real goal is previewing an existing archive, and the auto-load path had the same .gcode-filter gap as the picker. The viewer is now scoped to a single focused workflow — "show me the G-code for this archive" — reached from the Archives page 3D-preview button (menu item + the card-corner badge + list-row menu, all three paths navigate the same way). Entry URL is /gcode-viewer?archive=<id>[&plate=<N>]; the route falls through to the SPA catch-all so a full-page reload keeps the Bambuddy layout shell, with the iframe at /gcode-viewer/?archive=<id>… serving the raw viewer. Bed size is fetched from GET /archives/{id}/capabilities.build_volume (already parsing printable_area + printable_height from the 3MF's Metadata/project_settings.config) so any printer model renders the correct bed — 350×320×325 for H2D etc. — with no hardcoded per-model map to maintain. Multi-plate archives now surface a dedicated plate picker modal (components/PlatePickerModal.tsx) with thumbnails and object lists matching the existing Re-print modal's visual language; source-only 3MFs (no sliced gcode) show a archives.platePicker.noGcode toast instead of sending the user to an empty viewer. Behind the scenes: GET /archives/{id}/gcode accepts ?plate=N and resolves the filename by integer-matching the suffix (zero-padded names like Metadata/plate_01.gcode now resolve as plate 1, fixing a class of picker-claimed-but-404 archives); GET /archives/{id}/plates gained a top-level has_gcode: bool flag so the frontend can suppress the picker when the archive is source-only; printer_state_to_dict now injects name and model into every WebSocket snapshot so consumers don't race a separate /printers fetch for proper labels. Removed from the viewer: printer selector + WS subscription, library file picker, BAMBU_BED_SIZES hardcoded map, auto-load-currently-printing, sidebar nav entry, 32 orphaned gcodeViewer locale keys, and the unreachable ModelViewerModal render paths on archive cards (the File Manager still uses ModelViewerModal for library file previews — scope preserved). Added test coverage: ?plate=N happy path, zero-padded filename resolution, missing-plate 404, no-plate fallback to first, ?plate=0 400 rejection, has_gcode=true/false branch, plus PlatePickerModal.test.tsx (6 tests covering render, plate-name label, onSelect payload, backdrop close, thumbnail fallback) and printer_state_to_dict name/model surfacing tests. A toast replaces the old silent empty viewer for source-only archives; reload stays in the Bambuddy layout; H2D previews no longer overflow the bed.GET /printers/{id}/status endpoint now returns current_archive_id (resolved by matching the MQTT subtask_id against PrintArchive.subtask_id, the same bridge introduced in #972 for restart-resume) and current_plate_id (parsed from the MQTT gcode_file path by a new shared parse_plate_id helper that's also used by the WebSocket push path, so plate transitions within a running print reflect immediately instead of waiting 30 s for the next REST poll). The card fetches plate metadata via the same api.getArchivePlates() call the Queue page uses — shared React Query cache keeps it cheap across polls — and renders the actual plate name (or a "Plate N" fallback) only when the source 3MF is multi-plate, so single-plate prints stay noise-free. Falls back to the previous plate_(\d+).gcode regex when there's no archive linkage (e.g. prints started directly from the printer LCD). Regression tests cover the plate-id extraction across Bambu Studio path shapes and the label-override precedence in formatPrintName. Thanks to @stringham for the follow-up and screenshot.Clear Plate & Start Next button inside a yellow-bordered card (PrinterQueueWidget.tsx) whenever the plate-clear gate was up and an auto-dispatch item was queued — on top of the card-level "Mark plate as cleared" button introduced by #939. Both POSTed to the exact same /printers/{id}/clear-plate endpoint with identical optimistic-update semantics, so in that one state combination users saw two visually distinct affordances doing the same thing. Removed the widget's button and its entire needsClearPlate render branch; the card-level button (which is unconditional when plate-clear is required, and therefore already handles the staged-only and empty-queue cases that the widget couldn't) is now the single entry point. The widget becomes a pure passive "Next in queue" preview linking to /queue. No backend change, no change to the plate-status pill placement inside the Status box (deliberately kept where it is), and no change to compact-view (Size S) behaviour — the plateStatusPill at PrintersPage.tsx:2664/2671 and the icon-only round clear-plate button at :2673 are untouched. Also dropped the now-dead awaitingPlateClear / requirePlateClear / printerState props from PrinterQueueWidgetProps and the matching call site at PrintersPage.tsx:2810, and the orphaned queue.clearPlate / queue.plateReady translations from all eight locale files (queue.clearPlateSuccess is retained — still used by the card-level button's success toast). The dedicated PrinterQueueWidgetClearPlate.test.tsx suite (654 lines) was removed since every test asserted the behaviour of the now-gone button; PrinterQueueWidget.test.tsx continues to cover the passive-link path. Thanks to @EdwardChamberlain for flagging the duplication in #1079._watchdog_print_start in print_scheduler.py gives up at 45 s and reverts the queue item to pending if gcode_state hasn't flipped away from pre_state, on the assumption that a non-transitioning printer means the MQTT project_file publish was swallowed by a half-broken session (#887/#967). H2D Pro firmware (01.01.00.00) routinely keeps gcode_state=FINISH for 48–55 s after actually accepting the command before transitioning to PREPARE — logs from the reporter show the revert firing at +45 s and a legitimate PRINT START detected arriving just ~3 s later — so the watchdog reverted an item that the printer had already started physically printing. The physical print ran to completion and updated the linked archive (via register_expected_print), but the queue item was now pending again; on the next scheduler tick after the user cleared the plate, the same item was re-dispatched as if it had never run. With multiple items queued, item N+1 getting dispatched during the 45 s race window looked like forward progress to the user and masked the duplicate revert/re-dispatch of item N. Fixed in _watchdog_print_start by adding a second "command landed" signal: subtask_id changing past the pre-dispatch value. Bambuddy already mints a unique submission_id per project_file publish (capped at int32 post-#1042) and assigns it to subtask_id / task_id in the command payload; the printer echoes this back on the next push_status as soon as it starts processing — well before gcode_state transitions on slow-transition models. _start_print now captures pre_subtask_id alongside pre_state and passes both to the watchdog, which treats either a state change or a subtask_id advance as proof the command landed. Timeout raised 45 s → 90 s as belt-and-braces for printers that neither transition state nor echo subtask_id inside the polling window. None of the earlier exit paths are weakened — genuine half-broken sessions (state and subtask_id both unchanged across the full window) still revert, still force the MQTT reconnect, and are still recoverable without a power cycle. Added eight regression tests in test_scheduler_watchdog.py covering: pickup via state change, pickup via subtask_id change while state stays at FINISH (the exact #1078 case), revert when neither signal changes, default timeout of 90 s, pre_subtask_id=None fallback to state-only, status.subtask_id=None not mis-detected as a change, printer disconnect mid-watchdog (no DB write), and the #967 race where the item already moved on (completed). No frontend or MQTT changes — purely tightens the "did the printer accept?" decision. Thanks to @VREmma for the clear reproduction and the full support bundle that made pinpointing the H2D state-lag behaviour possible.awaiting_plate_clear gate but stored it on PrinterManager._awaiting_plate_clear (a per-process set, persisted to printers.awaiting_plate_clear via #961), not on PrinterState — and printer_state_to_dict() in printer_manager.py, which builds every WebSocket printer_status payload, was never updated to emit it. Only the HTTP endpoint GET /printers/{id}/status (line 634) surfaced the flag. That left the frontend in a deadlock: when print_complete arrived over the WebSocket, useWebSocket.ts intentionally didn't invalidate ['printerStatus'] (avoiding the render-cascade freeze the comment at line 235 warns about), expecting the subsequent printer_status WS messages to "naturally update the status" — but those messages carried no awaiting_plate_clear field, so the merge at line 146 preserved the stale false. The only path that ever surfaced true was the 30 s HTTP fallback poll at PrintersPage.tsx:1430, and on a chatty printer each incoming WS tick's setQueryData bumped React Query's dataUpdatedAt, pushing the next fetch further out — which is why the delay varied from ~30 s to several minutes. The plate-status pill at PrintersPage.tsx:1672-1675 rendered "Plate Clear" (the fallback label for falsy awaiting_plate_clear) during the entire stale window, compounding the confusion. Fixed by emitting awaiting_plate_clear from printer_state_to_dict: the function already has printer_id, so it reads printer_manager.is_awaiting_plate_clear(printer_id) directly and returns False when no id is passed (for the few callsites that don't have one). No frontend change needed — the existing WS merge path now carries the flag end-to-end, the "Clear Plate" button appears instantly on completion, and the queue-dispatch side of the gate (which already reads the in-memory set directly via print_scheduler.py:1125) is unaffected. Regression tests in test_printer_manager.py assert the WS dict always contains the key and that it surfaces True when the manager has the flag set for that printer_id. Affects every printer equally because the path is transport-agnostic — not an H2D- or A1-specific problem, just more visible on H2D because its longer finish sequence gave the poll slip more opportunities to miss.<input type="password"> fields but no accompanying username input, so password-manager browser extensions (1Password, Bitwarden, Chrome/Safari built-in) scanned the current DOM for a matching username anchor and latched onto the nearest type="text" input with no name/autoComplete — which happened to be the Printers-page search bar — and overrode its rendering. Fixed on two levels: (1) added a hidden <input type="text" name="username" autoComplete="username" value={user.username} readOnly hidden> at the top of the Change Password modal so password managers have a proper anchor and stop hunting elsewhere — as a bonus, saved new passwords are now correctly keyed to the logged-in user; (2) hardened the Printers-page search input with type="search", name="printer-search", autoComplete="off", and data-1p-ignore / data-lpignore="true" so any future heuristic-based autofill also skips it.87a5aa36) which only corrected Bambuddy's own modal. Root cause: when Bambu Cloud's GET /cloud/settings/{setting_id} returns a user preset with filament_id: null and base_id: "GFSB99_07" (cloud doesn't mint a distinct filament_id for presets that only override fields of a generic base), ConfigureAmsSlotModal.tsx:382-384 fell back to convertToTrayInfoIdx(base_id) which strips the version suffix and the S prefix → "GFB99" — Generic ABS's filament_id. The printer accepted and reported back GFB99, so both the LCD and OrcaSlicer correctly resolved the slot to Generic ABS. The fallback was never right: the preceding default already set tray_info_idx = convertToTrayInfoIdx(selectedPresetId) which for any PFUS*/PFSP* setting_id returns the base setting_id itself (via the helper's startsWith('PFUS') branch added earlier), and the printer + both slicers round-trip that format unchanged — confirmed by existing backend integration tests (test_configure_pfus_sent_directly, test_pfus_slicer_filament_used_directly), by the print scheduler's slot-matching which already expects P* short-form IDs in the printer's reported tray_info_idx (print_scheduler.py:910), and by the inventory Assign Spool flow which has been sending PFUS* preset IDs to the printer for months. The buggy fallback overwrote the correct default with a generic mapping. Fixed by removing the base_id branch: when cloud detail carries a distinct filament_id we still prefer it, otherwise we keep the setting_id-derived default. BambuStudio Sync now resolves the custom preset cleanly; OrcaSlicer (whose user presets don't carry a filament_id field at all, only inherits) will continue to fall back to the inherited generic — that's an OrcaSlicer preset-format limitation, not something Bambuddy can fix on its side, and the behaviour is strictly not worse than before. Regression tests in ConfigureAmsSlotModal.test.tsx pin four paths: (1) cloud detail with filament_id: null → tray_info_idx is the PFUS* setting_id, (2) cloud detail with a concrete filament_id → that filament_id wins over the default, (3) GFS* Bambu presets skip the cloud-detail fetch entirely and still map to the short GF* filament_id, and (4) a 5xx / network error on the cloud-detail fetch degrades gracefully to the PFUS* default instead of aborting the configure flow. An end-to-end backend test (test_configure_pfus_preserves_setting_id_pair) locks in that both tray_info_idx=PFUS… and setting_id=PFUS… survive the HT-slot POST /slots/{ams}/{tray}/configure path untouched. Thanks to @mrnoisytiger for the detailed browser-console / network / backend-log diagnostic data that isolated the fallback path, and for sharing the OrcaSlicer preset JSON that showed the missing filament_id field.rgba Bricks the Entire Filaments Inventory Page (#1055) — A user's Filaments page went blank and "Add Spool" became a no-op with no visible error. The backend was returning HTTP 500 from GET /api/v1/inventory/spools with fastapi.exceptions.ResponseValidationError: rgba → 'FFFFFFF' should match pattern '^[0-9A-Fa-f]{8}$' — a single legacy spool row had a 7-char rgba (missing one trailing F) and Pydantic's strict pattern on SpoolResponse refused to serialize the whole list because of it. Root cause spans three layers: (1) SpoolUpdate had no rgba pattern constraint, so PATCH calls could plant malformed values straight into the DB (SpoolCreate did validate, but only on initial create); (2) the ColorSection hex input's onChange ternary val.length <= 6 ? 'FF' : '' silently emitted 7-char strings for 5-char or 7-char typed input (5 chars + FF alpha = 7 chars; 7 chars got no alpha appended at all), which then flowed to the unvalidated PATCH endpoint; (3) SpoolResponse inherited the same pattern as SpoolCreate, so any malformed row already in the DB exploded the entire list endpoint on serialize even though write-side validation was the right place for the check. Fixed on all three layers: SpoolUpdate.rgba now carries the same ^[0-9A-Fa-f]{8}$ pattern as SpoolCreate, so PATCH requests with malformed rgba are rejected with 422 at the boundary. The hex input always emits a fully-formed 8-char RRGGBBAA on every keystroke — 8-char paste passes through, 7-char drops the stray char, shorter input is right-padded with '0' and given FF alpha. SpoolResponse.rgba is now an unconstrained Optional[str]: the pattern belongs on request schemas where Pydantic can reject bad input, not on responses where it turns a single bad row into a total page failure. A legacy malformed row still appears in the UI (the color just renders as whatever browser default applies) but the user can see, edit, and delete it instead of having to hand-edit SQLite. Backend tests cover all three schema contracts (16 cases across SpoolCreate accept/reject, SpoolUpdate accept/reject, SpoolResponse lenient-tolerance on 7-char / null / garbage). Frontend tests cover the hex-input normalization for every input length 0–8 plus non-hex strip-and-pad. Thanks to @fdsghy4a for the end-to-end debugging and for locating the exact malformed row in their DB.FileUploadModal to persist the file as a LibraryFile, then PrintModal dispatched a library print through POST /library/files/{id}/print, which uses the LibraryFile as the source for both the archive copy and the FTP upload to the printer. When the dispatch finished, both the LibraryFile row and its disk file in data/library/ were left behind, so every one-off Direct-Print accumulated an unwanted File Manager entry that the user had to find and delete manually. The other three print entry points are untouched: Archive "Reprint" never involved the library, and File Manager "Print" / Project Detail "Print" are paths where the user deliberately put the file in the library, so their entries are preserved. POST /library/files/{id}/print now accepts an optional cleanup_library_after_dispatch boolean. When true, _run_print_library_file stages the LibraryFile row for deletion in the same transaction as the archive insert (so a mid-flight FTP or start_print failure rolls back both at once, leaving no orphan), commits together, then unlinks the library disk file and thumbnail from disk after commit succeeds. External library files (is_external = True, pointing at user-managed folders outside Bambuddy's control) are never touched regardless of the flag. The Printers-page Direct-Print flow is the only caller that sends true; every other api.printLibraryFile call site leaves the flag unset so default-False preserves their library entries. Added two unit tests at the enqueue level (default-false + flag-propagates-true), two integration tests at the endpoint level (default-false + forwards-true + cleanup flag never leaks into the MQTT options dict), and two frontend tests on PrintModal guarding that cleanupLibraryAfterDispatch only forwards when explicitly set — so future File Manager / Project Detail entry points can't accidentally inherit the Direct-Print semantics. Thanks to @3823u44238 for flagging the surprising side effect.f03d0c4c) plumbed the authenticated user from POST /library/files/{id}/print into the background-dispatch job object, but the dispatcher itself never read it back out: _run_print_library_file called ArchiveService.archive_print() without the created_by_id parameter and never called printer_manager.set_current_print_user(). Net effect: direct prints from the printer-card "Print" button, File Manager prints, and Library prints all continued to land archives with created_by_id = NULL (invisible to the per-user stats filter), and the post-print email notification had no user to target. The dispatcher now forwards job.requested_by_user_id to the archive at creation time and registers the current-print user after start_print succeeds — matching the reprint path's behaviour. Reprint-from-Archive attribution is a separate bug (the reprint reuses the source archive row as-is, so a NULL created_by_id stays NULL) and is tracked on #730. Thanks to @3823u44238 for the thorough end-to-end retest.Content-Security-Policy: The page's settings blocked the loading of a resource (frame-src) at http://<host>:7912/spool because it violates the following directive: "frame-src 'self' https:". Root cause: commit 53a70e37 (#995) tightened the CSP to allow external sidebar iframes but only whitelisted https:, overlooking that self-hosted services on LANs — Spoolman, OctoPrint, etc. — almost always run over plain HTTP. The frame-src directive now allows http: as well (frame-src 'self' http: https:), matching the connect-src 'self' ws: wss: pattern already used for WebSockets. frame-ancestors 'none' still prevents Bambuddy itself from being framed cross-origin. Thanks to @saint-hh for reporting.ams_filament_setting command succeeded and BambuStudio / the printer's LCD both rendered the correct custom preset. Root cause: the GET /api/v1/printers/{id}/slot-presets endpoint keyed its response dict by ams_id * 4 + tray_id, which collapses cleanly to the same integer the frontend uses for regular AMS slots (0 through 15) but produces 128 * 4 + 0 = 512 for HT-A — a key nothing looks up. The frontend's PrintersPage HT render path calls getGlobalTrayId(ams.id, …, false) which returns the ams_id itself (128 for HT-A), and SpoolBuddy's AMS page used a third, unrelated formula ((amsId - 128) * 4 + trayId + 64 = 64). All three agreed for regular AMS so the mismatch only surfaced on HT, where the saved preset name never reached the UI and the render fell through to tray.tray_type → rendered as "Generic PLA". Backend now keys the response via a _slot_preset_key helper that mirrors frontend getGlobalTrayId (HT → ams_id, regular/external → ams_id * 4 + tray_id), and SpoolBuddyAmsPage uses the shared getGlobalTrayId helper instead of its home-grown formula. Regression test covers the key scheme for regular, HT, and external slots. Thanks to @mrnoisytiger for the detailed reproduction.G28 Z over the gcode_line MQTT command. Bare G28 Z skips the toolhead-park step that a full G28 runs first, so the bed raised without stopping at a safe height — in the reporter's case the toolhead happened to be parked on the purge chute and no damage was caused, but hitting the button with a toolhead anywhere else would have driven the bed into it at full Z speed. Root cause was the /api/v1/printers/{id}/home-axes endpoint's per-axis gcode mapping ("z" → "G28 Z", "xy" → "G28 X Y", "all" → "G28"). The endpoint now ignores the axes argument entirely and always sends a bare G28, which Bambu firmware expands into the safe multi-step sequence (park toolhead → home XY → home Z). The MQTT client helper BambuClient.home_axes() has the same change. The bed-jog modal is retitled "Auto Home" and its copy now says "parks the toolhead, then homes X, Y, and Z" so users aren't surprised when X/Y motion happens first. After a successful Auto Home click, the modal no longer re-prompts on the next jog in the same session — the "not homed" warning is gated on a session-scoped acknowledgement flag that was only being set by "Move anyway" and now also fires on successful Auto Home. Regression test covers all three axes arguments producing the same bare G28. Thanks to @mikefromdot for catching this with an undamaged retest.c9efa4b8 (#784) added a tray?.state === 10 gate to the EmptySlotHoverCard actions, intended to show the buttons only when a spool was physically present but not loaded (state=10) and hide them on truly empty slots (state=9). In practice, firmware often reports state=9 (or no state field at all) after a user-initiated reset — even when a spool is still physically in the slot — so the actions disappeared exactly when the user needed them. The gate is redundant anyway (EmptySlotHoverCard is only rendered when the slot has no tray_type, so it's definitionally empty from Bambuddy's perspective), and configuring an empty slot is a valid "tell the printer what will be loaded here" operation. The gate is now removed at both the standard-AMS and AMS-HT render paths. (2) After configuring a slot with a Generic profile (e.g. "Devil Design PLA Basic Red"), the Assign Spool modal didn't list the matching inventory spool unless the user enabled the "Show all spools" toggle. Root cause: the filter at AssignSpoolModal.tsx:144 required normalizeValue(spool.slicer_filament_name) === normalizeValue(trayInfo.profile) — manually-added inventory spools typically don't have slicer_filament_name populated, so they failed the exact-profile check even when the material matched. The filter now prefers an exact slicer-profile match when both sides advertise one, and falls back to partial material match in either direction (so e.g. a spool with material="PLA" is selectable for a slot reporting "PLA Basic") when profile info is missing. (3) Once the matching spool was assignable, a "profile mismatch" confirmation dialog still warned on every assignment because Bambu Studio / OrcaSlicer slicer-profile names carry a printer/nozzle/variant qualifier after @ (e.g. "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)") while the tray stores only the bare base name ("Devil Design PLA Basic"), and checkProfileMatch compared the full strings. Both the filter and the mismatch check now strip the @… qualifier before comparing, so identical base profiles are treated as a match. Regression test covers a spool with no slicer profile being surfaced for a slot whose profile + material are both set. Thanks to @TravisWilder for the report.requested_count and failed_count so the UI can surface a useful "Created N of M" message.location field is now exposed as storage_location in the unified inventory schema and editable from the spool detail panel.color_name, nozzle_temp_min, etc.) produce per-field warnings surfaced as a UI toast. After a successful NFC write the tag UID is persisted back to Spoolman's extra.tag via a safe key-merge that preserves all other custom extra fields.filament.spool_weight from Spoolman to compute the tare, falling back to 250 g when unset. Weight is written back to Spoolman's remaining_weight.extra: {}, destroying any custom Spoolman extra fields. The endpoint now fetches the current extra dict, drops only the tag key, and PATCHes the remainder.id field caused _map_spoolman_spool to raise ValueError, crashing GET /spoolman/inventory/spools with HTTP 500. The list endpoint now logs-and-skips individual bad rows so the rest of the list is returned normally.SpoolmanNotFoundError on 404 and SpoolmanUnavailableError on other failures; routes map them to 404 and 503 respectively._get_spoolman_client_or_none now runs assert_safe_spoolman_url before initialising the client; unsafe URLs are silently ignored with a warning log so devices continue operating.<img> wrapped its src with withStreamToken() (which appends the short-lived camera-stream token to /api/v1/ URLs that <img> tags can't attach an Authorization header to), but the enlarged lightbox <img> used a bare ${status.cover_url}?view=top so the browser's unauthenticated request was rejected by the backend. Both images now go through withStreamToken(). Thanks to @elit3ge for the report and screenshot.project_id / subtask_id / task_id from hardcoded "0" to str(int(time.time() * 1000)), each submission sent a 13-digit epoch-millisecond value (~1.7×10¹²). P1S firmware (observed on 01.10.00.00) clamps oversized task identity fields to signed int32 max (2147483647), so every dispatch looked identical from the printer's perspective — it treated a fresh print as a continuation of the prior FAILED job, returned result: success for project_file (command accepted), but then sat at gcode_state: IDLE with an empty gcode_file instead of transitioning to PREPARE/RUNNING. Thanks to @EdwardChamberlain for pinpointing the exact line and suggesting the mod fix. The three identity fields are now set to str(int(time.time() * 1000) % 2_147_483_647 or 1): modulo keeps values inside the signed-int31 window with a ~24-day uniqueness cycle (more than enough for reprint deduplication), and or 1 guards against the astronomically unlikely zero case (the printer rejects task_id=0). Regression test test_submission_id_fits_signed_int32 asserts all three IDs are < 2**31. Two of @EdwardChamberlain's other suggestions — resolving bed_type from the sliced 3MF's per-plate JSON instead of hardcoding "auto", and gating dispatch success on an actual state transition to PREPARE/RUNNING rather than on project_file's result: success — are larger changes tracked separately./app/data/archives/temp/ with the File Manager unable to find it. Root cause: download_file_async wrapped the blocking FTP RETR in asyncio.wait_for with a 30–60 s timeout (user-configurable via ftp_timeout), but the wrapped thread couldn't be cancelled. On a slow link the download would overshoot the timeout by 15–30 s, at which point _run() waited a hard-coded 0.5 s for the zombie to finish, gave up, and returned failure — which triggered with_ftp_retry attempt 2, whose _download spawned a brand-new FTP session that contended with attempt 1's still-running transfer. Attempt 1's zombie eventually completed and wrote the file to disk, but by then attempt 2 (and 3, 4) had long since run out their own timeouts with their own fresh completion dicts and reported failure; the archive pipeline saw only the final None from with_ftp_retry and created a fallback archive row with no 3MF data, which is why Skip-Object couldn't find the plate's objects even though the 3MF was on disk. Two fixes: the 0.5 s post-timeout sleep is replaced with a threading.Event the worker sets in its finally block, and _run() waits for that event with a bounded grace of max(min(ftp_timeout, 30), 0.5) s — covering the slow-WiFi overshoot case without extending a genuinely stuck connection indefinitely. The log line now includes the grace window (timed out after Xs (plus Ys grace)). Regression test test_download_file_async_timeout_waits_for_slow_zombie simulates a 1.5 s zombie with a 1.0 s wait_for timeout; old 0.5 s sleep would give up, new 1.0 s grace salvages. The existing test_download_file_async_timeout_no_salvage_when_incomplete still passes — a thread that never completes within the grace window still returns failure. Thanks to @heffe2001 for the detailed reproduction and support logs.SNAPSHOT_CAPTURE_TIMEOUT (the first keyframe from the printer's camera can take a while on a cold RTSP connection). Subsequent polls every ~8 s recovered and captured in ~1.2 s, but the red × Failed to capture snapshot for printer N banner in Settings → Failure Detection → Status stayed up forever because ObicoDetectionService._last_error was written on failure and never cleared on the next successful poll. The successful branch in _check_printer now clears _last_error to None once a capture + ML call + classification complete, so the banner reflects only errors from recent cycles. Configuration-level errors (missing external_url, missing ml_url) still persist because they return before the clearing line — users still see them until they fix the setting. Regression test covers: seed _last_error, run one successful _check_printer, assert _last_error is None. Thanks to @fblix for the reproduction and screenshot.max-[550px]:flex-wrap rule on the left badge group that only fires below 550 viewport pixels, so on a wide viewport with a narrow card the left group never wrapped — and since its badges don't truncate, Chrome painted the overflowing speed/bed-jog badges on top of the right-pinned Stop/Pause buttons. German locales made it obvious ("Pausieren" is 9 characters). The left group now uses unconditional flex-wrap, so when badges don't all fit on one line they wrap inside the left cell instead of colliding with the right cell; the parent row also wraps gap-y so Stop/Pause drops to a new line in the worst case. Pre-existing (commit 4ff3e2a6, Feb 2026), surfaced while testing #939.main.py (~line 4120) still used the legacy single-topic model (mqtt_topic plus *_path kwargs), while the Settings UI save path had been upgraded to the newer per-type model (mqtt_power_topic / mqtt_energy_topic / mqtt_state_topic each with their own paths, multipliers and mqtt_state_on_value). Plugs configured entirely with the new per-type fields got skipped at startup because the if plug.mqtt_topic: guard short-circuited — which is exactly what a Shelly-via-ioBroker setup looks like, since those publish power and state on separate topics. The "rename, save, rename back" workaround triggered the update endpoint, which was using the correct per-type code and re-established the subscription. Fix: extracted the topic-resolution + service.subscribe() call into a single subscribe_plug_to_mqtt(service, plug) helper in backend/app/services/mqtt_smart_plug.py that preserves legacy fallback, and routed the startup restore, create, and update routes all through it so future schema changes can't cause the three paths to drift again. Regression tests cover: per-type topics restored without a legacy topic set, legacy single-topic backward compat, per-type multipliers overriding legacy, per-type winning when both are set, the empty-config skip case, and topic-list de-duplication. Thanks to @saint-hh for the clear repro steps.data/archives/ ended up not being a valid ZIP. The archive row was still written, the printer card looked fine, and the problem only surfaced later when opening the archive in the UI, where GET /archives/{id}/plates logged Failed to parse plates from archive N: File is not a zip file and the thumbnail / plate / filament panels came up blank. Two things conspired: shutil.copy2 takes the Linux sendfile() fast path on Python ≥ 3.8, and a partial-return from that syscall silently truncated the destination for the upload sizes users hit; and ThreeMFParser.parse() had a bare except: pass around its zipfile.ZipFile open, so the archive pipeline kept going with empty metadata and left the bad file on disk. The copy is now an explicit chunked read/write with fsync() — no sendfile involved — with a post-condition zipfile.is_zipfile() check that refuses to create the archive row (and cleans up the archive directory) when the source was a valid ZIP and the destination isn't, logging both sizes at ERROR. The parser's silent catch now logs at WARNING so corrupted 3MFs are visible in support bundles instead of disappearing into empty metadata. Regression tests cover small / multi-chunk copies, ZIP roundtrips, the post-copy is_zipfile sentinel on a truncated file, and the new parser WARNING. Thanks to @saint-hh for the detailed diagnosis.<img> tags can't send via Authorization headers, so the token is appended as ?token=… at render time. Two race conditions conspired to break this: (1) the token query was keyed only on ['camera-stream-token'] and fired while the user was still on the login page, 401'd, and stayed cached — after sign-in nothing invalidated it; (2) when the token did eventually arrive, the global variable holding it was not reactive, so any File Manager / Archives page that had already rendered kept serving image URLs with no token. The token query now includes the user id in its key and is gated on !!user, so a new login always triggers a fresh fetch; and when the token transitions from null to a value, useStreamTokenSync walks the DOM once and updates src on every already-rendered <img>/<video> pointing at /api/v1/ without the current token, reloading them in place.01.01.01.00 as the newest available release even though 01.02.00.00 had shipped on the Bambu Lab wiki weeks earlier, so the "update available" badge never appeared. Two silent regex mismatches in the wiki scraper caused _fetch_all_versions_from_wiki() to return an empty list: (1) the section-heading anchor parser required a dash between the version bytes and the release date (id="h-01020000-20260409"), but P2S and X2D publish anchors without the dash (id="h-0102000020260409"); (2) the text-based fallback only accepted ASCII parens around the date, while P2S, X2D, A1 and A1-mini headings render dates in full-width (YYYYMMDD) (U+FF08/U+FF09). When both paths failed, the code silently fell back to the Bambu Lab download page, which still lagged at 01.01.01.00. The anchor regex now accepts an optional dash and the fallback accepts both paren styles; added regression tests for the no-dash anchor and full-width paren shapes. Thanks to @Minebuddy for reporting.LibraryFile.print_count and last_printed_at are now updated on every successful queued print completion. Previously both fields were defined on the model and displayed in the File Manager, but nothing ever wrote to them — every file in every library showed as never printed. Now counts increment cumulatively and last_printed_at stamps the completion timestamp (UTC). Failed, cancelled and user-aborted prints are intentionally excluded, so the fields represent "successful usage" rather than "attempted usage." This unblocks sorting the File Manager by last-printed date and is a prerequisite for the scheduled-purge feature requested in #1008. Thanks to @cadtoolbox for the report.library-collapse-folders, matching the existing library-* preference pattern. Thanks to @AshieTashi for the request.python:3.13-slim-trixie instead of the Bookworm-based python:3.13-slim. Picks up ffmpeg 5 → 7 (HEVC/AV1 improvements for camera capture), OpenSSL 3.0 → 3.3, and two more years of APT package freshness. Frontend-builder stays on Bookworm until the Node.js image team publishes Trixie variants — users never see that stage.POST /printers/{id}/bed-jog and POST /printers/{id}/home-axes endpoints, both gated behind printers:control. Thanks to @cadtoolbox for the request.home_flag bit 23 on X1/X1C/X1E and the top-level stat hex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll).set_airduct MQTT command. Gated to P2S/H2D/H2C/H2S.pushall MQTT status report from the printer without forcing a reconnect.ml_api container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: Notify only, Pause print (MQTT pause command), or Pause and cut power (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Snapshots are captured locally with a 20 s timeout we control and stashed under a one-shot 32-byte nonce; the ML API fetches them via an unauthenticated /api/v1/obico/cached-frame/{nonce} URL that sidesteps Obico's hardcoded 5 s read timeout.01.01.03.00 which Bambu only ships as OTA). The currently installed version is highlighted with a blue Installed badge. Selecting any usable row swaps the release-notes block at the top to that version's notes and enables the Install button for it — including older-than-current versions, so you can roll back to a previous firmware without having to hand-flash a file. The wiki scraper was tightened to only extract version numbers from heading anchors (e.g. id="h-01030000-20260303") so incidental version mentions in release-note prose — like an AMS firmware reference in an H2D changelog — no longer get mistaken for H2D firmware releases. Thanks to @Cornelicorn for the request./spoolbuddy/devices/{id}/update and /spoolbuddy/devices/{id}/system/command endpoints — no new backend work needed. Thanks to @TravisWilder for the request.Settings table instead of filtering by a hard-coded allowlist: sensitive keys (tokens, passwords, URLs, paths, emails, etc.) have their values replaced with [REDACTED] but the key itself is kept, so new config flags automatically show up in future bundles without a code change. Also adds an integrations.spoolbuddy section listing registered SpoolBuddy devices (firmware version, NFC/scale hardware, calibration, online state, uptime) — anonymized, no hostnames/IPs/device IDs.frontend/src/lib/settingsSearch.ts) so future settings register themselves next to their component instead of being forgotten in a central array.python-multipart 0.0.22 → 0.0.26 closes CVE-2026-40347 (GHSA-mj87-hwqh-73pj), a denial-of-service triggered by large preamble or epilogue data around a multipart boundary — the 0.0.26 release now skips the preamble before the first boundary and silently discards the epilogue after the closing one. Bambuddy uses python-multipart transitively through FastAPI/Starlette for form and file-upload parsing, so any authenticated endpoint accepting multipart/form-data (e.g. backup restore, project thumbnail upload) was exposed. dompurify 3.3.3 → 3.4.0 picks up the fix for GHSA-39q2-94rc-95cp (the function-form ADD_TAGS could bypass FORBID_TAGS); Bambuddy's two call sites (ProjectDetailPage, ProjectPageModal) only use array-form ALLOWED_TAGS/ALLOWED_ATTR, so the specific bypass was not reachable, but the bump still hardens the sanitizer against future misconfiguration and clears the audit warning.b069b521 serial-adaptation fix. OrcaSlicer's Linux builds publish MQTT payloads with the C-string null terminator included in the length (same pattern as paho.mqtt.c #1198), so every decoded message arrived as {…}\x00. The virtual printer's strict json.loads() raised JSONDecodeError: Extra data and the handler silently returned — no pushall, get_version, or project_file was ever answered, so the slicer hit its 60 s sync timeout and reconnected in a loop. Real Bambu firmware's mosquitto passed the trailing byte through, which is why direct LAN connections worked, and why print_queue mode was the only affected path (proxy mode tunnels MQTT to the real printer instead of running the VP broker). The handler now strips trailing \x00/whitespace before parsing and logs the raw payload on any remaining decode failure so future silent variants are visible in support bundles. Thanks to @EdwardChamberlain for the debug-enabled support log that made the null byte visible in the raw bytes.spoolbuddy/install/install.sh --mode full produced an unusable kiosk on first boot: Chromium raced ahead of uvicorn and showed "can't connect to localhost"; after a manual reload the kiosk URL /spoolbuddy?token=… was hijacked by Bambuddy's first-run wizard (AuthContext force-redirects to /setup whenever requires_setup=true, regardless of the target path); the wizard asks for admin credentials, but a touch-only Pi has no on-screen keyboard; if the user skipped auth the browser landed at / instead of the kiosk, and if they tried to enable auth they were stranded. Standalone mode was unaffected because it runs against an already-configured remote Bambuddy. Fixed in three parts: (a) new backend/app/cli.py with a kiosk-bootstrap subcommand that in a single DB transaction creates a scoped API key (can_read_status=True, can_queue=False, can_control_printer=False) and upserts setup_completed=true, so the first-run wizard never triggers and the kiosk URL loads the SpoolBuddy page directly; users can still enable authentication later from the admin UI and the pre-provisioned key keeps working. (b) install.sh full-mode now runs the CLI as the bambuddy service user immediately after create_bambuddy_service and sed-replaces the CHANGE_ME_AFTER_SETUP placeholder in spoolbuddy/.env. (c) The generated spoolbuddy-kiosk-launch now polls ${backend_url}/health with a 60 s timeout before exec'ing Chromium, so cold boots wait for uvicorn instead of flashing the connection-refused error. The CLI is idempotent with --force for re-installs.N6 via SSDP/MQTT, and serials begin with 20P9. Because neither the code nor the prefix existed in any of Bambuddy's model tables, multiple paths silently fell back to wrong defaults: the camera service routed to the chamber-image protocol on port 6000 (which the X2D doesn't speak) instead of RTSP on port 322 — the reporter saw Chamber image: data is not a valid JPEG spam and no stream; the K-profile edit/delete path conditioned its in-place cali_idx write on the H2D serial prefix 094 and would therefore have treated X2D as a single-nozzle printer even though its dual-extruder layout matches H2D; the firmware-update check logged Unknown printer model: N6; and the virtual-printer model registry had no way to emulate X2D. Added the N6 → X2D mapping across every registry (PRINTER_MODEL_ID_MAP, PRINTER_MODEL_MAP, ETHERNET_MODELS, STEEL_ROD_MODELS, CHAMBER_TEMP_SUPPORTED_MODELS, firmware-check API keys and wiki path, virtual-printer SSDP product names and serial prefix, DB migration vp_model_fixes), extended supports_rtsp() to match X2 display names and the N6 internal code (camera now goes to port 322), expanded the dual-nozzle serial prefix check in kprofiles.py and the K-profile delete command in bambu_mqtt.py to also accept 20P9 so the H2D-style cali_idx in-place edit path runs on X2D, added X2D to the is_h2d model-family gate that selects the integer-format timelapse/bed_leveling/flow_cali/vibration_cali/layer_inspect fields in the MQTT print command, and added X2D to the frontend's door-badge and airduct-mode whitelists, mapModelCode lookups on both the Printers page and Spoolbuddy AMS page, and the MaintenancePage wiki-URL resolver (X2D inherits P2S's steel-rod lubrication, belt-tension, nozzle cold-pull and PTFE wiki pages, since its hardware is closer to P2S than to H2). Credit to @krautech for the report and the debug bundle, and to @legend813 for the initial PR (#989) that seeded most of the registry changes — the classification was corrected (X2D uses hardened steel rods like P2S, not carbon rods) and the dual-nozzle/K-profile gaps were added on top.spd_lvl and updating state.speed_level correctly, but the WebSocket serializer (printer_state_to_dict) was missing the field — so live status pushes never carried speed_level, and the frontend's merge-over-old-cache update left the icon stuck on its previous value. The REST /status endpoint used on initial page load already included it, which is why reloads worked. Added speed_level to the WebSocket payload. Thanks to @chesterakl for reporting."Valid camera stream token required", while the embedded overlay kept working. Two root causes: (1) window.open(...) passed noopener in the popup features, which severed the opener link and prevented the browser from copying sessionStorage (where the auth token lives) into the popup — so the new window booted unauthenticated and the POST /printers/camera/stream-token fetch returned 401, leaving the <img> src without the required ?token= query param; (2) even once the token arrived, CameraPage computed its URL from the module-level stream-token cache on render and never re-rendered when the cache was updated in a useEffect, so the first paint locked in a tokenless URL that the backend kept rejecting. Fixed by dropping noopener from the camera popup features (same-origin, trusted window) so sessionStorage is inherited, subscribing CameraPage to the camera-stream-token React Query so it re-renders the moment the token resolves, and appending the token directly from the reactive query value instead of the effect-synced module cache — the <img> src stays empty until the token is ready, so no tokenless request ever leaves the popup. Embedded-overlay mode was unaffected. Thanks to @VREmma for the reproducer.developer_mode was unknown; after the initial probe cached the value, subsequent zombie states went undetected because neither the staleness timer nor the keepalive could distinguish a half-open connection from a healthy one. The MQTT client now tracks ams_filament_setting command/response pairs: when a published command receives no response within 10 seconds, it's counted as unanswered. After two consecutive unanswered commands, the session is force-reconnected using the same force_reconnect_stale_session() mechanism. This catches zombie sessions at the moment the user encounters them — on their second failed spool change — rather than requiring a manual reconnect. Thanks to @RosdasHH for the detailed support bundles that made the diagnosis possible./p/ endpoint is declared methods=['GET'] upstream and only reads ?img=URL as a query string (verified against obico-server/ml_api/server.py). Flask's router rejected every POST with 405 Method Not Allowed before any handler ran, which is why the Obico container logs showed zero activity while Bambuddy kept reporting ML API call failed for printer N: with a blank suffix — raise_for_status() on the 405 response produced an exception whose str() rendered empty in this path. Reverted to the pre-#1003 nonce-URL approach: the detection loop captures the JPEG locally with a 20 s timeout, stashes it under a 32-byte single-use nonce, and hands Obico a GET /api/v1/obico/cached-frame/{nonce} URL that resolves in <50 ms (so Obico's hardcoded 5 s read timeout never races our RTSP keyframe wait). The cached-frame route is un-authenticated at the Bambuddy layer — the unguessable 32-byte nonce with ~30 s TTL IS the credential. The warning log now also falls back to type(exc).__name__ when str(exc) is empty, so future silent exceptions can never produce a blank error again. For users behind reverse-proxy external auth (Authelia/Authentik/Cloudflare Access): the /api/v1/obico/cached-frame/ path must be whitelisted from external auth — it's already public on Bambuddy's side. Thanks to @fblix for the ml-api-shows-zero-logs clue that pinpointed the 405 root cause.fblix reported a permanent "Failed to capture snapshot" warning in the UI. The periodic camera stream cleanup task scans /proc for ffmpeg processes with Bambu RTSP URLs and kills any that aren't in the active-streams registry. The Obico detection service's capture_camera_frame_bytes() spawns its own short-lived ffmpeg process to grab a single JPEG frame, but that process was never registered with the stream cleanup — so when the 60-second cleanup cycle happened to run during the 5–10 s capture window, it killed the ffmpeg as "orphaned" (exit code -9). The detection service recovered on the next poll, but the kill produced unnecessary error logs and a missed detection frame. Fixed by tracking capture PIDs in a module-level set (_active_capture_pids) and excluding them from the /proc-scan kill list. Thanks to @fblix for the detailed timing analysis.created_by_id, so the resulting archive had no owner and the print didn't show up in per-user statistics. The Queue and Reprint paths already forwarded the authenticated user; the library POST /files/{file_id}/print endpoint now does the same, reading the user from the JWT and passing it through to the dispatcher so direct prints are attributed like queued and reprinted ones.calc(100vh - 2rem) with internal overflow so every field stays reachable regardless of viewport height. Thanks to @MartinNYHC for reporting.dry_sf_reason reports a blocking state (most commonly code 8 — AMS 2 Pro external power adapter not plugged in — but also "AMS busy", "already drying", etc.), and Bambuddy parsed that array but never surfaced it to the user; (2) the payload sent filament: "", which some firmwares treat as an invalid-field refusal. The /drying/start endpoint now inspects the live dry_sf_reason for the target AMS unit and returns a descriptive 409 (e.g. "Plug in the external AMS power adapter to start drying") instead of silently publishing, and backfills an empty filament from the first loaded tray's type (defaulting to PLA) so the printer never rejects the command for a missing field. Thanks to @MartinNYHC for reporting.httpx and httpcore loggers to DEBUG, which caused httpx to log the full URL of every outbound HTTP request. For Discord notifications and generic webhook notifications, the URL is the secret — the bearer token is embedded in the path — so any user who enabled debug logging (typically to capture logs for a bug report) was writing their Discord webhook token to bambuddy.log and then pasting it into GitHub issues or support bundles. httpx/httpcore are now pinned to WARNING regardless of the debug toggle; paho.mqtt still honours debug. If you enabled debug logging while notifications were sending, rotate any exposed Discord/webhook URLs — the token is in the path, so the whole URL must be regenerated in the provider's UI.project_file start command (same half-broken-session shape as #887/#936), the queue item was permanently orphaned in the printing status at 100% because the scheduler optimistically flipped the DB row to printing right after the publish succeeded locally and had no watchdog to revert it. Recovery required manually editing the SQLite print_queue table. A new watchdog now captures the printer's pre-dispatch state and polls for up to 45 s after start_print() returns; if the printer never transitions, the item is reverted to pending so the scheduler picks it up again, and the MQTT session is force-reconnected so the retry lands without a printer reboot. Thanks to @stringham for reporting.project_file MQTT command would be sent, but the printer never transitioned out of FINISH/IDLE and required a power cycle to unstick — after which it often started a previously cancelled print rather than the intended one. Root cause is a half-broken MQTT session (same shape as #887): the printer keeps publishing telemetry so Bambuddy reports it as connected, but our publishes on the command topic never reach the firmware. Existing recovery only triggered via the developer-mode probe path, which skips printers that already have a known developer_mode value. The print-dispatch verifier now treats an unacknowledged project_file (state unchanged after 15 s) as the same "commands not reaching printer" signal and forces a fresh MQTT session so the next dispatch can land without a printer reboot. The existing dev-mode probe path is refactored to share the same helper.IDLE and the next job auto-dispatched without the "Clear Plate & Start Next" confirmation. Root cause: the plate-cleared gate lived only in the in-memory PrinterManager._plate_cleared set, and the scheduler's idle check treated IDLE as always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now an awaiting_plate_clear column on the printers table, set by on_print_complete when a print finishes or fails, cleared by the /printers/{id}/clear-plate endpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB into PrinterManager on startup. _is_printer_idle now short-circuits to not-idle whenever require_plate_clear is on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back into IDLE. The clear-plate endpoint no longer requires the printer to currently report FINISH/FAILED (it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting.tempfile.mktemp(), which is vulnerable to a symlink race condition (CWE-377). Replaced with tempfile.mkstemp() which atomically creates the file, eliminating the TOCTOU window.X-Frame-Options: DENY on every response, which blocked even same-origin iframing. Relaxed to SAMEORIGIN so Spoolman (and any other same-origin tool behind the same reverse proxy) can be embedded again, while still preventing cross-origin clickjacking.printing archives by filename and marked them cancelled once older than 4 h — so a 13 h print that had a restart 10 h in got its archive cancelled, a brand-new archive created with started_at = now(), and the final duration displayed as ~1.5 h for a job that actually ran 13 h. Fixed by persisting the MQTT-provided subtask_id on every archive row (new subtask_id column, auto-added via the existing inline migration runner) and matching on that id first, regardless of age. Same id means same print; the row is resumed in place with its original started_at. Also revives Stale-cancelled rows from the legacy path if an earlier Bambuddy version already ran the old cancel-then-recreate logic. (2) 3MF search retried non-existent paths for ~48 min: the path order was /cache/ → /model/ → /data/ → /data/Metadata/ → /, and every missing path burned the full retry budget (user had ftp_retry_count = 10 with 30 s delay ⇒ 11 × 30 s × 4 missing paths ≈ 22 min before the real / root path was even tried). BambuStudio/OrcaSlicer actually push to / on A1-family printers, so the "most likely" path was tested last. Fixed by reordering to try / first, and by raising a new FileNotOnPrinterError sentinel from download_to_file when the FTP response is a 550 (file not found) so with_ftp_retry's non_retry_exceptions short-circuits instead of waiting out the full delay ×11 retries against a path that will never have the file. Transient errors (425 "can't open data connection", SSL EOF, connection resets) still retry as before. (3) Same 36 MB downloaded twice — the cover-thumbnail endpoint and the archive-metadata handler each opened their own FTP session for the same file during the print, and the second session often hit 425 because the first was still using the printer's single FTP socket. Added a small in-memory _threemf_path_cache keyed on (printer_id, normalized filename): whichever flow fetches the 3MF first populates the cache, the other flow reuses the file read-only, and on_print_complete evicts the entry + deletes the temp file. Normalization collapses Broly_X, Broly_X.3mf, Broly_X.gcode.3mf, Broly X, and case variants to the same slot so both flows agree on the key. Net effect for the reproducer: what took ~48 min with a lost start time now takes seconds and the archive keeps its original row + timestamps. Thanks to @mstko for the reproducer and support bundles.ftp_timeout setting (default 30 s, reporter had raised it to 300 s) was only plumbed through as the FTP socket timeout; the outer asyncio.wait_for wrapping run_in_executor was stuck on the hardcoded 60 s default, so the user's 300 s value never applied — every 3MF download was capped at 60 s regardless. (2) asyncio.wait_for cannot cancel run_in_executor threads: when the 60 s outer timeout fired, the executor thread kept running ftplib.retrbinary and frequently completed the download successfully ~30–60 s later — logging "Successfully downloaded … N bytes" and caching the working FTP mode — but by then the async wrapper had already returned False, so the retry loop kept re-attempting the same path, each attempt truncating the file the zombie thread had just written. After all 4 attempts the wrapper reported failed after 4 attempts and the archive was persisted as a fallback (no 3MF, empty file_path). The async wrapper now (a) accepts and uses timeout at each call site so ftp_timeout controls both the asyncio deadline and the socket deadline, and (b) salvages a post-timeout success: when the executor thread has set an explicit completion flag and the file is on disk, the wrapper returns True instead of discarding the result. Also fixes a cosmetic // prefix in the directory-search download path (posixpath.join replaces string concatenation that produced "//file.3mf" when the search dir was "/"). Thanks to @MartinNYHC for the report and @PurseChicken for the P1S support bundle.sdcard field is only sent on some pushes with inconsistent typing, and home_flag bits 8-9 are cleared on heartbeat pushes even when a card is inserted, with no reliable way to distinguish heartbeats from full status reports. The badge has been removed entirely from the Printers page card and the Printer Info modal. Underlying state.sdcard parsing is retained (simplified to a plain truthy read of the sdcard field only, no more home_flag derivation, no heartbeat latches) because the firmware-update precondition check still needs to know whether a card is inserted before starting an update. Thanks to @MartinNYHC for the extensive reporting across all four rounds. Previously, this entry described the H2D badge flap and its three attempted fixes — kept here for history: The original bug toggled between "inserted" (green) and "not inserted" (red) every few seconds on H2D. Root cause: the MQTT parser used a strict identity check (data["sdcard"] is True) on the top-level sdcard field, but real firmware ships that field inconsistently — bool on some models, int 1, or a string enum like "HAS_SDCARD_NORMAL" on others — so any message carrying a non-bool value flipped the state to False. Fixed by deriving the badge from home_flag bits 8–9 (HAS_SDCARD_NORMAL / HAS_SDCARD_ABNORMAL) when present — the canonical firmware source, same as door and store-to-SD parsing — and falling back to a truthy check on the top-level field for firmwares that only send that. Follow-up: the badge was still flapping because Bambu firmwares send partial MQTT pushes that carry the legacy sdcard field alone (without home_flag), and the fallback was re-engaging on every such push. The parser now latches home_flag as the canonical source for the session once seen, so partial pushes carrying only sdcard can no longer flip the badge; the latch resets on reconnect so a firmware change still re-learns. Second follow-up: on H2D the badge still showed red on initial Printers-page navigation and flipped to green on reload, because H2D also sends heartbeat-style home_flag pushes where bits 8–9 are clear even when a card is inserted. Downgrades from true→false now require three consecutive clear reads (upgrades false→true still apply immediately), so a single heartbeat no longer turns the badge red. Third follow-up: the three-strike counter still lost the race on idle printers — once an A1 or other printer connecting nearby triggered a burst of MQTT activity, idle H2Ds could accumulate ≥3 heartbeat pushes before the next full status report and all flip to red simultaneously. Reworked the derivation: the legacy top-level sdcard field is now authoritative when present (truthy check covers bool/int/string firmware variants), home_flag bits 8–9 are only consulted on full push_status reports (identified by the presence of multiple state markers like gcode_state, mc_percent, nozzle_temper, print_type, stg_cur, or ams), and bare heartbeat pushes carrying home_flag alone no longer affect SD state at all. Thanks to @MartinNYHC for reporting.project_file command with hardcoded project_id="0", subtask_id="0", task_id="0", and md5="", while BambuStudio mints unique identity fields per submission. The printer uses those IDs to key per-job state (including gcode_start_time), so when every reprint arrived under the same task_id=0, the printer reused the prior job's start timestamp instead of emitting a fresh state-transition event — third-party tools that derive duration from that timestamp latched onto a stale value, and successive replays compounded the error. bambu_mqtt.start_print() now generates a per-submission millisecond timestamp for project_id/subtask_id/task_id and a unique md5 derived from the filename + timestamp, matching BambuStudio's per-submission-unique-ID behavior. Covers both archive reprints and direct prints from the Library. Thanks to @PurseChicken for the controlled A/B reproducer (Studio vs archive reprint) that pinpointed the divergence to the print-start command payload.Content-Security-Policy header added in 0.2.3b4 broke three things at once: (1) custom sidebar links pointing at external HTTPS URLs (e.g. a Grafana/telemetry dashboard) rendered in ExternalLinkPage were blocked because no frame-src was declared and iframes fell back to default-src 'self'; (2) the inline service-worker registration <script> at the bottom of index.html was blocked by script-src 'self', silently preventing the PWA service worker from installing; (3) the @import of Google Fonts' Inter from index.css was blocked by style-src and font-src. Fixed by adding frame-src 'self' https: for user-configured HTTPS iframe targets, moving the inline SW-registration script into /sw-register.js so script-src 'self' covers it without needing 'unsafe-inline' or per-build hashes, and allowing https://fonts.googleapis.com in style-src and https://fonts.gstatic.com in font-src. frame-ancestors 'none' is preserved so Bambuddy itself still cannot be framed cross-origin.DATA_DIR/backups/), which Docker users can mount as a volume to their NAS or external storage. Each backup in the list can be downloaded, restored directly from the UI, or deleted individually. The manual backup download endpoint has also been optimized to stream directly from disk instead of loading the entire ZIP into memory, significantly reducing download wait times for large backups. Works with both SQLite and PostgreSQL installs. Fully localized across all 7 UI languages.DELETE /spoolbuddy/devices/{device_id} endpoint (gated by inventory:delete) handles the removal and broadcasts a spoolbuddy_unregistered websocket event so other tabs refresh immediately. A yellow warning banner appears when more than one device is registered to flag likely crash-duplicates. If an online device is accidentally unregistered, it will re-register itself on its next heartbeat. The Settings tab header also shows a device-count badge and a green/gray bullet indicating whether at least one registered device is online. Fully localized in English, German, and Japanese..gcode and .gcode.3mf). No more round-tripping through File Manager to reprint project files. Prints triggered from the project view are automatically associated with the originating project, so the resulting archive shows up in that project's history without any manual assignment. Backend adds a project_id query parameter to GET /library/files that returns all files across linked folders in a single query (replacing the prior one-request-per-folder pattern) and validates project_id on both the direct-print and queue paths so a stale ID yields a 404 instead of a FK-constraint 500. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.wlopm, coexisting with swayidle which continues to handle touch-based wake independently. Gracefully degrades when wlopm is not installed or no Wayland session is available. Thanks to @TravisWilder for the suggestion.swayidle and wlopm, and labwc's autostart launches a new watchdog (spoolbuddy/install/spoolbuddy-idle.sh) that queries the backend once on boot for the device's display_blank_timeout and hands it to swayidle, which powers HDMI off via wlopm --off HDMI-A-1 after the configured idle period and powers it back on via wlopm --on when labwc delivers any input event (touch, keypress). The redundant CSS overlay and its pointer/keyboard listeners have been removed from SpoolBuddyLayout — one source of truth now. Screen blanking is opt-in: display_blank_timeout=0 (the default) skips launching swayidle entirely and the display stays on forever, preserving current behavior for users who didn't pick a timeout. The default for users who newly enable blanking is 300 seconds. Changes made to the timeout in SpoolBuddy Settings → Display take effect on the next kiosk restart — tap Quick Menu → Restart Browser to apply without a full reboot. A new GET /api/v1/spoolbuddy/devices/{device_id}/display endpoint (gated on inventory:update, same as the existing PUT and heartbeat endpoints) is what the kiosk-side watchdog reads, so no new permissions are required on the device's API key. The watchdog also writes a full startup trace (env vars, resolved timeout, the exact swayidle command it execs) to ~/.cache/spoolbuddy-idle.log so any future breakage on a different kiosk setup is trivially diagnosable, and auto-detects WAYLAND_DISPLAY from XDG_RUNTIME_DIR with a short retry loop in case labwc hasn't finished exporting its env by the time autostart runs. Thanks to @TravisWilder for reporting.device.nozzle.info entirely instead of sending an empty placeholder, so the rack arrived with 5 entries (IDs 17..21) plus the 2 L/R hotends. The frontend was computing its rack base ID via min(present_ids), which then became 17 instead of the fixed 16, and every remaining nozzle was rendered one position to the left — the nozzle physically in slot 2 appeared as "slot 1", slot 3 appeared as "slot 2", and so on, with the single empty placeholder falling off the right end as a phantom "slot 6" that should have been the actual empty "slot 1". The rack base is now hardcoded to 16 to match the fixed H2C rack ID layout (already encoded in the test_h2c_nozzle_rack_populated_with_8_entries backend test), so the empty slot stays anchored to its physical position regardless of which nozzle is currently in use. A frontend regression test exercises exactly this case (ID 16 missing, remaining slots in order) and asserts the rendered slot row reads [—, 0.2, 0.6, 0.8, 1.0, 1.2]. Thanks to @netscout2001 for reporting.asyncpg.DataError: invalid input for query argument $2: ... can't subtract offset-naive and offset-aware datetimes every hour and failed to persist any snapshots, so date-filtered energy statistics in total-consumption mode stayed empty on Postgres installs. The engine already had a before_cursor_execute hook that strips tzinfo from bound datetime parameters before they reach asyncpg (the smart_plug_energy_snapshots.recorded_at column is TIMESTAMP WITHOUT TIME ZONE to match the rest of the schema), but the hook only stripped datetimes one level deep — when SQLAlchemy's insertmanyvalues feature batched multiple snapshot rows into a single INSERT ... SELECT FROM (VALUES ...) statement, parameters arrived as nested containers (lists of tuples, or a list inside an outer container) and the inner datetimes slipped through untouched. The hook now recursively walks any nesting of dict/list/tuple and strips tzinfo at any depth, so every parameter shape SQLAlchemy may use is handled. SQLite installs were never affected (SQLite ignores tzinfo entirely).tray_id_name code (e.g. A17-R1) in a hardcoded table, and when the exact code wasn't listed they fell back to a suffix-only lookup (R1 → Scarlet Red). The suffix half of that code is not globally unique across material families — A17-R1 is PLA Translucent Cherry Pink, while A01-R1 is PLA Matte Scarlet Red — so the fallback was structurally guaranteed to produce wrong names for any color the hand-maintained list didn't happen to cover. The resolver has been rewritten to use the existing color_catalog table (seeded from catalog_defaults.py plus the FilamentColors.xyz sync) as the single source of truth. Backend lookup is now by hex color against the catalog; the frontend fetches a compact {hex: name} map once per session via a new GET /api/inventory/colors/map endpoint (available to any authenticated user, not gated on inventory:read), stores it in a ColorCatalogProvider context, and uses it for all getColorName() calls. The hardcoded tables in backend/app/core/bambu_colors.py, frontend/src/utils/colors.ts, and frontend/src/pages/PrintersPage.tsx have been removed entirely. Existing spools that were auto-created with a wrong name before this fix need to be renamed manually — the fix only affects new auto-provisioning and live display. Thanks to @lightmaster for reporting.sqlite3.IntegrityError: NOT NULL constraint failed: users.password_hash and fell through to a 500 response, because the users table on disk had been created before LDAP support landed with password_hash VARCHAR(255) NOT NULL. The model was already nullable=True and the migration to drop the constraint existed, but only ran on PostgreSQL — SQLite was skipped entirely because it has no ALTER COLUMN ... DROP NOT NULL. The migration now patches sqlite_master directly via PRAGMA writable_schema and bumps PRAGMA schema_version so the current connection reloads the table definition without requiring a restart. Fresh installs were never affected (they go through Base.metadata.create_all which uses the current nullable model). Thanks to @DylanBrass for reporting._print_energy_start) that was lost on any backend restart mid-print, so prints that spanned a restart never got an energy delta computed; (2) historical prints from before a smart plug was added had no value at all. The fix replaces the in-memory dict with a persisted energy_start_kwh column on the archive row, and adds an hourly snapshot loop (smart_plug_energy_snapshots table) that captures each plug's lifetime counter. The /archives/stats endpoint now computes date-range totals via per-plug (last-in-range − baseline) deltas from those snapshots, clamping counter resets to zero. A warming-up flag is returned (and rendered as a tooltip next to the Energy stats on StatsPage) when the query runs on incomplete snapshot history — e.g. right after upgrade, before the hourly loop has built up a baseline before the selected range — so the "low" values during the first hours after upgrading are explained in-product rather than misread as a bug. Fully localized across all 7 UI languages. Per-print energy tracking is now restart-resilient in all modes as a side-effect. Thanks to Mike (@TheMadMike23) for reporting.f"device/{self.serial}/request" in topic — if the slicer's cached serial for the VP didn't exactly equal the VP's computed self.serial (which depends on model prefix + per-VP serial_suffix), every get_version, pushall, and project_file publish was silently dropped. Nothing was logged past the initial "MQTT publish to …" line, so the slicer never received a push_status or get_version response on its subscribed device/{serial}/report topic and hit its sync timeout. Status pushes, version responses, and project_file acknowledgments were also being published on device/{self.serial}/report, so even when the incoming check happened to pass, replies targeted a topic the slicer wasn't listening on if its serial had drifted. Both directions are now serial-adaptive: the handler accepts any authenticated publish on a device/*/request topic, extracts the serial the slicer is actually using from the topic, stores it per-connection, and uses it for every outgoing status report, version response, print acknowledgment, and periodic push so responses always land on the topic the slicer subscribed to. The client's serial is cleared when the connection closes and when the server stops. Regression tests cover the mismatched-serial publish path, the non-request-topic rejection path, the pushall→status_report routing, and the client-serial lifecycle.{"detail":"Valid camera stream token required..."}. The sidebar <img> tag in Layout.tsx used a raw /api/v1/external-links/{id}/icon URL, but that endpoint is protected by a query-string stream token (the same mechanism used for camera streams and archive thumbnails, because <img> tags cannot send Authorization headers). The edit dialog already routed through api.getExternalLinkIconUrl(), which wraps the URL via withStreamToken(); the sidebar now does the same, so icons appear when auth is enabled.ssh-keygen and ssh binaries for keypair creation and command execution. Both binaries call getpwuid(getuid()) at startup and abort with No user exists for uid <N> when the container runs under an arbitrary PUID that is not listed in /etc/passwd (the stock python:3.13-slim image only has an entry for root, so running with user: "1000:1000", "1001:1001", or any non-root user tripped the same error). The entire SpoolBuddy update path is now subprocess-free: keypairs are generated in-process via the cryptography library (already a dependency), SSH commands run through the pure-Python asyncssh client, and git-branch detection reads .git/HEAD directly instead of shelling out to git. asyncssh also calls getpass.getuser() for local ~/.ssh/config host matching, which hit the same passwd lookup failure; the Docker image now sets LOGNAME=bambuddy, USER=bambuddy, and HOME=/app so getpass.getuser() resolves via env vars before touching the passwd database, and asyncssh.connect() is called with config=[] so it does not attempt to load ~/.ssh/config at all. Branch detection also now looks for .git/HEAD in the application root rather than settings.base_dir — in Docker the data directory is a separate volume (DATA_DIR=/app/data) that never contains .git. Finally, the Docker build now bakes .git/HEAD into the image (.dockerignore allows this single 20-byte file through the context filter) so the production image knows which branch it was built from; previously the .git directory was excluded from the build context entirely, leaving the container with no git metadata and causing the SpoolBuddy update flow to always pull main on the remote device regardless of which branch Bambuddy itself was built from. Native installs behave identically — they already worked because the running user was always in /etc/passwd and .git/HEAD was readable from the project root. Regression tests assert that neither keypair creation nor command execution spawns any subprocess, and that branch detection reads from the application root even when a decoy .git sits inside the data dir.memberUid (supplementary group membership). A user's POSIX primary group — referenced by the gidNumber attribute on the user object and matching the gidNumber on a posixGroup — was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches for posixGroup entries whose gidNumber matches the user's primary gidNumber, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec).virtual_printer_remote_interface_ip setting value unmasked in support-info.json. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added _ip to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.isSuccess state persisted from the first plate-clear confirmation, causing the component to render the static "Plate Ready" confirmation instead of the clickable button. The mutation state is now reset when the printer leaves the FINISH/FAILED state, so the button works correctly on every print cycle.DATABASE_URL environment variable (e.g., postgresql+asyncpg://user:pass@host:5432/bambuddy) to connect to Postgres. SQLite remains the default when no DATABASE_URL is set. All features work with both backends including full-text archive search (FTS5 on SQLite, tsvector+GIN on PostgreSQL), backup/restore (file copy vs pg_dump/pg_restore), health diagnostics, and cross-database restore (import a SQLite backup into PostgreSQL with automatic type conversion and FK handling).0.001 to convert Wh to kWh). Useful for platforms like ioBroker that expose each data point as a separate API endpoint./upload, /upload-bulk, /{id}/source, /source-by-name, /{id}/f3d, /{id}/timelapse) used the client-supplied filename directly in file paths without stripping directory components. An authenticated attacker could write files outside the intended directory via directory traversal (e.g. ../../evil.3mf). All upload endpoints now sanitize filenames by extracting only the basename before constructing paths. Reported responsibly by Sacha Vaudey via security@bambuddy.cool./start-logging, /stop-logging, /submit) had no authentication, allowing anyone on the network to enable debug logging, retrieve system logs, and trigger bug report submissions with system diagnostics when authentication was enabled. All three endpoints now require authentication — start-logging requires settings:update permission, stop-logging and submit require settings:read. Endpoints remain open when authentication is disabled (the default). Reported responsibly by Sacha Vaudey via security@bambuddy.cool.printer_ids list ([]) was treated identically to null (global access to all printers), granting full printer access instead of no access. Now null means global access (admin key) and [] means no printer access. Existing API keys with empty lists are automatically migrated to null on startup. Also fixed the webhook queue endpoint which used a falsy check that would bypass the filter for empty lists. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.X-Content-Type-Options: nosniff (prevents MIME-sniffing), X-Frame-Options: DENY (prevents clickjacking via iframe embedding), and Referrer-Policy: strict-origin-when-cross-origin (limits URL leakage to external services) on every response. Content-Security-Policy was omitted because the React SPA uses inline styles extensively and a permissive CSP would provide no meaningful protection. Strict-Transport-Security was omitted because Bambuddy is a LAN application commonly accessed over HTTP — HSTS would lock users out. Reported responsibly by Sacha Vaudey via security@bambuddy.cool./tmp with default 0644 permissions, making them readable by any local user. Switched from NamedTemporaryFile(delete=False) to mkstemp with explicit 0600 permissions so only the application user can read them. Cleanup was already handled via finally blocks. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.archive_id was None at print completion so the entire 3MF tracking path was skipped — now searches library files and previous archives by filename to find the 3MF even without an archive, and captures the AMS slot-to-tray mapping at print start so it's available at completion regardless of archive state; (5) when auto-archive was disabled but the print was dispatched by BamBuddy (queue/reprint), the on_print_start callback discarded the expected print entry and returned early — the archive was never promoted to _active_prints, so at completion archive_id and ams_mapping were both None, making all tracking paths fail. Now detects expected prints before the auto-archive early-return and falls through to the normal promotion path, also injecting the stored ams_mapping into the usage tracker session.delete_folder, delete_file, bulk_delete) relied on FastAPI's dependency cleanup auto-commit which runs after the response is sent — the frontend received the success response, refetched the folder/file list, but the delete hadn't been committed yet. Added explicit db.commit() before returning in all three endpoints.FINISH state, which triggered two independent deduction paths in the same event loop cycle: the AMS weight sync (absolute SET from remain%) and the usage tracker (additive delta from 3MF data). The AMS weight sync now skips updates while a print session is active, letting the usage tracker handle deductions precisely via 3MF slicer data.consoleblank=0 to the kernel cmdline to disable Linux console blanking during the Plymouth-to-labwc transition, and changed the wlr-randr anti-blank loop to fire immediately instead of sleeping 60 seconds first.printing → completed) could fail silently if the SQLite database was locked by another writer (e.g. the runtime tracker). The failed commit left the job permanently stuck in printing status — a "ghost job" that caused the UI to show false double-assignments when the next job started. The critical queue status commit now retries up to 3 times with backoff on SQLite lock errors (PostgreSQL is unaffected — it uses row-level locking). Additionally, the runtime tracker was holding a single long transaction across all printers; it now commits per-printer to minimize lock hold time.inventory_changed websocket event, and the frontend invalidates the spool cache on receipt — so SpoolBuddy (and all other tabs) reflect changes instantly.developer_mode as null), but had no timeout or recovery — one unanswered probe permanently blocked retries. Added a 10-second probe timeout with one retry; after two consecutive unanswered probes, Bambuddy force-closes the socket to trigger a clean reconnect with a fresh session. Additionally, the developer mode probe was firing on every auto-reconnect, which destabilized some firmware MQTT brokers (A1/P1 series) — causing a reconnect → probe → disconnect feedback loop. The probe result is now cached across reconnects and only runs once on the first connection, with a 5-second delay after connect to let the session stabilize.fun Field (#873) — Connecting to printers that don't send the MQTT fun field (A1, P1 series, X1Plus firmware) caused a repeating 'str' object has no attribute 'get' crash in the WebSocket handler, showing the printer as offline with missing AMS and SD card info. The developer mode probe introduced in 0.2.3b1 published an MQTT message inside _update_state() between overwriting raw_data with the full MQTT dict (where vt_tray is a raw dict) and restoring the previously normalized list — the publish() call released the GIL, letting the event loop read the un-normalized dict and iterate over string keys instead of spool dicts. Fixed by normalizing vt_tray dict→list in the MQTT data before assignment, and moving preserved field restoration before the probe. Added defensive normalization in printer_state_to_dict as a belt-and-suspenders guard.stats:filter_by_user permission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select "No User (System)" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports.event, printer, filename, duration, etc.) alongside the existing title, message, timestamp, and source fields. Previously, only title and message were sent, requiring automation tools to parse the message text for event details. All event-specific template variables are now included as top-level JSON fields, making it easy for n8n, Node-RED, Home Assistant, and other automation platforms to route and process notifications based on structured data. Slack/Mattermost format is unchanged.fun field in MQTT status (A1, P1 series) now have developer mode detected via a probe command. After receiving the first full status update, Bambuddy sends a no-op external slot configure and checks whether the printer accepts or rejects it (mqtt message verify failed). Printers that do send the fun field (X1C, H2D, etc.) continue to use the existing bit-based detection. Developer mode state is re-checked on every reconnect.bed_temper in MQTT updates after a print finishes — even in response to pushall requests — causing the cached value to stay frozen at the end-of-print temperature until the monitor timed out. The new approach registers a waiter at print completion and reacts instantly when bed_temper data arrives via MQTT, whenever that may be. No timeout, no polling, no stale data — the notification fires as soon as the printer reports the bed is at or below the configured threshold.tray_sub_brands. Now detects gradient/multi-color/tri-color variants from the tray_id_name color code pattern (M*/T* suffixes).use_ams: true. Now automatically sets use_ams: false when all filament slots map to external spools. (2) Printers with an AMS connected but empty (e.g. X1C with ams_exist_bits=1, tray_exist_bits=0) got stuck at heatbed heating or hit the same 07FF_8012 error because the print command used ams_id: 254 in ams_mapping2 instead of 255. The firmware interpreted 254 as a physical AMS tray target instead of external spool. BambuStudio uses ams_id: 255 (VIRTUAL_TRAY_MAIN_ID) for single-nozzle external spool. Fixed by mapping external spool to ams_id: 255 on all non-H2D printers. H2D dual-nozzle printers retain 254 (deputy) / 255 (main) distinction._thumbnail_data) that were stored directly in the database JSON column without cleaning. Also removed a call to the non-existent parser.extract_thumbnail() method — thumbnail data is already available in the parsed metadata. Now uses the same clean_metadata() pattern as upload and zip extraction.on_print_complete callback returned early before reaching the usage tracking code, so neither the internal inventory (AMS remain% deltas) nor Spoolman received usage data. Moved filament tracking to run before the archive check so usage is always recorded regardless of the auto-archive setting.ams_mapping2 format used a shared ams_id: 255 with slot_id: 0/1 to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 ams_mapping2 format change (per-tray ams_id instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly.finally block, and adding an error toast with the actual API message.subtype, brand, rgba, or color_name on the SpoolBuddy dashboard crashed the UI (black screen). The spool prop construction used displayedSpool?.subtype ?? sbState.matchedSpool!.subtype — when the field was null, the ?? operator fell through to sbState.matchedSpool which could also be null, causing a TypeError. Fixed by picking one source object instead of mixing per-field fallbacks. Added a global React error boundary so future crashes show the error instead of a black screen.PlateSelector component used them directly in <img src> without appending the stream token. Fixed by passing the URL through withStreamToken().datetime-local input used sr-only positioning which anchored the picker off-screen. Fixed by positioning the hidden input inside the date field's container.--js-flags=--max-old-space-size=128 crashed the V8 renderer when heap exceeded 128 MB, --enable-low-end-device-mode aggressively killed GPU rendering surfaces, and resetting CHROMIUM_FLAGS discarded the Pi's GPU defaults (--enable-gpu-rasterization, ANGLE/GLES) creating an unstable mixed CPU/GPU rendering path. Fixed by removing both flags, appending kiosk flags to Pi defaults instead of replacing them, adding a wlr-randr keep-alive loop to prevent display blanking, and adding <screenBlankTimeout>0</screenBlankTimeout> to the labwc config.datetime.now(timezone.utc) (timezone-aware) but the recorded_at column stores naive datetimes via SQLite's func.now(). The mismatch caused a TypeError when SQLAlchemy processed the comparison. Fixed by using a naive UTC datetime for the cutoff. The error only appeared once per ~24h because the cleanup runs every 288 recording cycles (288 × 5 min = 24h).animate-ping with scale transforms, blur-2xl glow, continuous animate-pulse on status dots) with static elements and a slow color-cycling spool (5s interval). Chromium: added --disable-extensions, --disable-background-timer-throttling, --disable-renderer-backgrounding, and --disable-crash-reporter to /etc/chromium.d/spoolbuddy-kiosk. WebSocket: SpoolBuddy Dashboard and Layout pages now use React Query select to extract only connected status from printer queries, so temperature/fan/progress updates no longer trigger re-renders on every MQTT tick. Services: stripped services are now masked (not just disabled) to prevent socket/dbus reactivation; user-level services (xdg-desktop-portal, mpris-proxy, pipewire, etc.) are masked globally via /etc/systemd/user/ overrides instead of unreliable su -l systemctl --user. Removed chromium and upower from strip_packages since the kiosk needs them — they were being uninstalled then immediately reinstalled on every run./etc/apt/apt.conf.d/80-preserve-wifi) that backs up NetworkManager WiFi connections before every apt upgrade and restores them if they get wiped. Prevents headless SpoolBuddy Pis from losing WiFi connectivity after Raspberry Pi OS package upgrades (observed with Bookworm kernel/raspi-config updates that clear /etc/NetworkManager/system-connections/).apt-get upgrade -y after installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade.disable_filament_warnings setting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal./proc and /sys — no additional dependencies required. Usage bars turn amber at 70% and red at 90%; CPU temperature is color-coded green/amber/red.generate_splash.py) is included for easy customization. Also reduced redundant initramfs rebuilds during install by deferring the rebuild until after the Plymouth theme is configured.<img>/<video> tags now require a stream token query parameter (?token=xxx) when authentication is enabled. Previously these endpoints were unauthenticated because browser media elements cannot send Authorization headers. The frontend obtains a 60-minute reusable token via POST /printers/camera/stream-token (requires CAMERA_VIEW permission) and automatically appends it to all media URLs. Affects endpoints in camera, archives, library, printers, print-log, and external-links routes. When auth is disabled (default for local installs), behavior is unchanged — no token required..git/ directory absence as a Docker fallback, but LXC containers may also lack .git/ depending on how the install was deployed. Replaced the .git/ fallback with a proper check of /run/systemd/container which only matches Docker/Podman/OCI runtimes, not LXC. Native installs in LXC containers now correctly show the in-app update button instead of Docker Compose instructions.url field (ftp://file name.3mf) contained unencoded spaces that the firmware couldn't parse. Fixed by replacing spaces with underscores in the remote filename before upload.formatSlotLabel to display the full slot label (e.g. "Low Filament: PLA (B2) - 4% remaining").read_tag.py diagnostic script had five issues preventing NTAG reads: (1) SAK 0x04 (MIFARE Ultralight family) was rejected as "unsupported tag type" — now accepts both 0x00 and 0x04. (2) ntag_read_pages had TX CRC off (should be on per NTAG spec), no Crypto1 clear, and no IDLE→TRANSCEIVE state reset. (3) The PN5180 enters an unrecoverable state after an NTAG READ command — added full GPIO hardware reset between each 4-page batch. (4) Reading past the end of smaller tags (MIFARE Ultralight has 16 pages vs NTAG's 44+) caused a hard failure — now returns partial data gracefully. (5) ntag_write_page/ntag_write_pages had the same stale CRC/state issues plus unreliable ACK checking and post-write verification — synced with daemon.tag_uid but left tray_uuid, tag_type, and data_origin intact. All tag-related fields are now cleared together.0x04 (MIFARE Ultralight family) instead of 0x00 during anticollision — both 0x00 and 0x04 are now accepted. (2) TX CRC was disabled for NTAG commands but the spec requires it — enabled for both WRITE and READ. (3) The PN5180 state machine needed IDLE→TRANSCEIVE resets (not just set_transceive_mode()) and Crypto1 cleared before NTAG operations. (4) The 4-bit WRITE ACK cannot be captured by the PN5180 (SOF detected but no RX_IRQ) — removed per-page ACK checking. (5) Post-write read-back verification also failed (second READ command gets no response from the PN5180) — removed verification since the tag reliably ACKs each write.QueuePool limit of size 10 overflow 20 reached, connection timed out errors. Increased the SQLAlchemy connection pool from 30 total (10 base + 20 overflow) to 220 (20 base + 200 overflow), and raised the SQLite busy_timeout from 5 to 15 seconds to reduce write contention under heavy concurrent MQTT updates.APP_VERSION from the backend config..git/, hardcoded main branch) with SSH-based updates driven by the Bambuddy backend. Bambuddy now SSHes into the SpoolBuddy Pi and runs git fetch/checkout, pip install, systemctl restart, and kiosk browser restart remotely. Updates automatically use the same branch as Bambuddy. SSH key pairing is fully automatic — Bambuddy generates an ED25519 keypair and includes the public key in the device registration response; the daemon deploys it to authorized_keys on first connect. The install script creates the spoolbuddy user with a bash shell and sudoers entries for daemon and kiosk restart. A "Force Update" button allows re-deploying even when versions match. The SSH public key is also shown in SpoolBuddy Settings → Updates → SSH Setup for manual pairing if needed.Cache-Control: no-cache to the sw.js endpoint so browsers always pick up new service worker versions immediately. The SpoolBuddy kiosk now skips SW registration entirely and unregisters any existing SW — a touchscreen kiosk has no use for offline caching and it was the main source of stale frontend issues after updates.network-online.target so Chromium has connectivity when it starts."complete" after the daemon had already re-registered, overwriting the cleared state; (2) the kiosk restart navigated away from the updates page; (3) query cache served stale data. Fixed by letting daemon re-registration clear all update status, removing the kiosk restart in favor of a frontend-driven window.location.reload() triggered via WebSocket when the daemon comes back online, and adding proper loading states to Check/Force Update buttons.{id, state} in incremental MQTT updates — filament load/unload transitions now update in real-time without requiring a reconnect.<spoolman_url>/spool._completion_triggered = True when a terminal state is first seen without a prior RUNNING state so the flag is clean for the next print cycle.0700_8012 "Failed to get AMS mapping table", while the same print worked fine from BambuStudio. Bambuddy was passing raw virtual tray IDs (254/255) in the flat ams_mapping array, but BambuStudio converts these to -1 and relies on ams_mapping2 for external spool routing. The H2D firmware rejects raw 254/255 in the flat array. Also fixed the ams_mapping2 format for external trays — each virtual tray is its own AMS unit with slot_id: 0, not a shared unit differentiated by slot.0x7FFFFF) on its first conversion after power-up, which polluted the moving average and made the initial weight report wildly inaccurate. Fixed by flushing the first reading during init() so all subsequent reads return valid data. Also extracted both hardware drivers out of diagnostic scripts into proper modules — the NAU7802 scale driver from scripts/scale_diag.py into daemon/nau7802.py, and the PN5180 NFC driver from scripts/read_tag.py into daemon/pn5180.py. The production daemon was importing driver classes from test scripts since the original SpoolBuddy commit. Removed the now-unnecessary sys.path hack from main.py.__version__ that was never bumped to 0.2.3b1, causing the update check to incorrectly show an update from 0.2.2b1 to the latest release. Fixed by reading the version at import time from the backend's APP_VERSION in backend/app/core/config.py — the single source of truth — so the daemon version is always in sync.update_status and update_message to the device model but was missing the database migration, causing "no such column" errors on existing installations.rc.is_failure) are never suppressed by the spurious-disconnect filter. The disconnect event used by disconnect() is fired unconditionally at the top of the callback so that no early-return filter can prevent it from unblocking callers. Reported by @inkdawgz.stg_cur=0 when idle, which maps to the "Printing" stage name and overrides the correct "Idle" gcode_state on the printer card. The System Info page was unaffected because it displays the raw gcode_state. Extended the existing A1/A1 Mini workaround for this firmware bug to also cover P1S and P1P models. Reported by @inkdawgz.REDIRECT rule rewrites the destination IP to the incoming interface's primary address, so all FTP traffic went to the first VP regardless of the intended target. Fix: FTP server now binds directly to port 990 (standard implicit FTPS), eliminating the need for iptables redirect. Requires CAP_NET_BIND_SERVICE (already set in the systemd service and Docker image). Also removed a global set_exception_handler() in the MQTT server that caused spurious error messages when running multiple VPs. See docs/migration-vp-ftp-port.md for migration steps. Reported by @VREmma.3DPrinter-X1-Carbon / 3DPrinter-X1) that BambuStudio doesn't recognize, causing "incompatible printer preset" when sending. Fixed to use the correct codes (BL-P001 / BL-P002). Also fixed proxy mode auto-inherit storing the printer's display name (e.g. X1C) instead of the SSDP code. Existing VPs are automatically migrated on startup. Reported by @RosdasHH.border-black/20) across all views: Inventory, Archives, Assign Spool, Configure AMS Slot, Calendar, Projects, Filament Trends, Local Profiles, Link Spool, and Spoolman Settings. Reported by user.image field when a snapshot is available (generic format only, not Slack format). Reported by @Arn0uDz.timezone import in email timestamp, unused lambda argument, PEP 8 blank line spacing for mark_printer_stopped_by_user, and SQLAlchemy forward reference in UserEmailPreference model.Title and Message HTTP headers were passed as Python strings, causing httpx to reject them with UnicodeEncodeError. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user.rtsp_url, net.info[].ip), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding transparent TCP proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298.bambu_networking DLL silently refused to proceed with the actual 3MF upload, showing a login modal instead. Root cause: the DLL validates the TLS connection parameters and rejects connections where the certificate doesn't match the printer's real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's own "Virtual Printer CA" certificate, which the DLL rejected. Fixed by switching to transparent TCP proxying for FTP (port 990), FileTransfer (port 6000), Camera (port 322), and FTP passive data (ports 50000–50100) — raw bytes are forwarded without TLS termination, so the slicer gets end-to-end TLS directly with the printer's real certificate. Only MQTT (port 8883) remains TLS-terminated, which is required to rewrite the printer's real IP with the proxy's bind IP in MQTT payloads. Confirmed working on both H2D and X1C printers.UserEmailPreference SQLAlchemy model was not imported in models/__init__.py, causing mapper initialization failures when the User model's relationship resolved the string reference before the model class was registered with Base metadata.install.sh systemd service template was missing AmbientCapabilities=CAP_NET_BIND_SERVICE, causing Virtual Printer proxy mode to silently fail to bind privileged ports (322, 990) on native installations.bracket.gcode.3mf to bracket), making the file unprintable. The rename modal now only lets users edit the base name, with the extension shown as a non-editable suffix. Reported by @fleishmaab, confirmed by @cadtoolbox.tray_exist_bits=0 and power_on_flag=false, which caused Bambuddy to clear all AMS slot data and auto-unlink every spool assignment. On reconnect, the assignments were gone. Fixed by skipping tray_exist_bits slot clearing when power_on_flag is false (shutdown message), preserving AMS data across printer restarts. Reported by @Woyteck1.notify services in addition to the existing REST-based integration. Contributed by @mrtncode.parse()). Dev-only dependency (eslint).printers:control permission when authentication is enabled.dry_sf_reason from printer firmware and surfaces HMS error codes for AMS 2 Pro and AMS-HT power issues.manual_start set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.{eta} template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing {estimated_time} still shows duration ("1h 23m"). Requested by @SebSeifert./spoolbuddy/write-tag) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type application/opentag3d, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with data_origin=opentag3d. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.react-simple-keyboard with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with data-vkb="false" are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard./spoolbuddy/ams) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the ConfigureAmsSlotModal to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.ProtectedRoute requires a user object from GET /auth/me, which only accepted JWT tokens. The /auth/me endpoint now also accepts API keys (via Authorization: Bearer bb_xxx or X-API-Key header) and returns a synthetic admin user with all permissions. The frontend's AuthContext reads an optional ?token= URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (/spoolbuddy?token=${API_KEY}), so the device authenticates automatically on boot without manual login.docker-publish-daily-beta.sh) that reads the current APP_VERSION from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., 0.2.2b1) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as latest. Fixed auto-generated "Contributors" section appearing in GitHub release notes by stripping @mentions from changelog text before creating the release.last_scale_weight and last_weighed_at on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.db.flush() instead of db.commit(), so the new row was only written to the database after the response was sent to the client. The frontend immediately refetched the file list upon receiving the response, but a new database session couldn't see the uncommitted row — resulting in stale data. Fixed by committing before the response is returned. Also fixed the same race condition in folder create, folder update, and file update endpoints. Reported by @shadowjig.QueuePool limit reached errors and greenlet_spawn failures in RFID spool auto-assignment. Doubled the pool to 30 connections (size 10 + overflow 20). Reported by @shadowjig.inventory:view_assignments permission saw 0% fill level on AMS slots where inventory spool data had stale weight_used values. The fill level fallback chain (Spoolman → Inventory → AMS remain) used nullish coalescing (??), which doesn't fall through on 0 — so a stale inventory fill of 0% permanently shadowed the correct real-time AMS remain value from the printer. Now, when inventory says 0% but the AMS hardware reports a positive remain, the inventory value is bypassed in favor of the live AMS data. Viewer users were unaffected because their group lacked inventory:view_assignments, so the inventory query never fired and the AMS remain was used directly. Reported by @cadtoolbox./cloud/settings, /cloud/fields, preset CRUD) requiring settings:read / settings:update permissions instead of cloud:auth — users who had "Cloud Auth" enabled but "Settings" disabled couldn't load profiles after logging in. Reported by @cadtoolbox.compatible_printers filter parsed the stored JSON array as a semicolon-delimited string, so the matching always failed and every local preset was silently skipped. Removed the filter entirely — user-imported profiles should be available on any printer.eth0:1) added for multi-virtual-printer setups were invisible in the bind IP dropdown. The Docker image didn't include iproute2, so the ip command wasn't available and the code fell back to ioctl-based enumeration which can only return one IP per interface. Added iproute2 to the Docker image.touchstart/touchmove/touchend) to both the header drag handle and the resize handle, with preventDefault to stop page scrolling during drag. Reported by @dsmitty166.turn_on, which is a no-op when the switch is already on. Now sends toggle for non-script entities so the badge click actually toggles the switch state. Script entities still use turn_on (stateless trigger).scalar_one_or_none() which raises MultipleResultsFound. Now fetches all plugs and returns the main (non-script) power plug, matching the API route behavior.smart_plugs.printer_id (to allow multiple HA switches per printer) used an exact string match to detect the constraint in the SQLite schema. Databases created with older SQLAlchemy versions expressed the constraint differently (e.g. quoted column names, table-level UNIQUE(printer_id), or separate indexes), so the migration silently skipped them. Users hit IntegrityError: UNIQUE constraint failed when assigning a second HA switch to a printer. Now uses regex pattern matching and also checks for standalone UNIQUE indexes.home_flag bit 18 is set on all printers regardless of connection type, so every ethernet-capable model showed the ethernet badge even when connected via WiFi. Replaced bit 18 detection with wifi_signal-based heuristic: printers on ethernet with WiFi disabled report a hardcoded -90 dBm sentinel, while real WiFi signals vary. (2) The lazy import used from app.utils.printer_models which crashes with ModuleNotFoundError in paho-mqtt's background thread (correct path is backend.app.utils.printer_models). This killed the MQTT thread entirely, causing all printers to go stale after 60s and repeatedly disconnect/reconnect. (3) WiFi-only models (A1, P1P, etc.) that don't have an ethernet port are excluded via model-based gating. Reported by @cadtoolbox.slot_id - 1 which can never reach external spool IDs (254/255) or AMS-HT IDs (128+). Added position-based resolution using sorted available tray IDs from the printer's AMS state, matching the fix applied to Spoolman tracking in #686. Contributed by @shrunbr.filament_id for versioned setting IDs (GFSL99 → GFL99), ignoring variant suffixes (GFSL99_01). Added a cross-check that compares the resolved filament name against the spool's stored preset name and corrects the filament ID via reverse lookup when they don't match (e.g. GFL99 → GFL96 for "Generic PLA Silk"). Also fixed the UI showing a stale preset name (e.g. "Bambu PLA Matte" instead of "Bambu PLA Silk") after assignment — the slot preset mapping was only saved when assigning via SpoolBuddy, not via the PrintersPage hover card. The backend now saves the slot preset mapping using the spool's authoritative slicer_filament_name after every successful MQTT configuration, regardless of which UI path triggered the assignment. Reported by @peter-k-de, @RosdasHH.GET /api/v1/support/debug-logging endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive datetime.now(), raising TypeError. Now strips timezone info when reading the stored timestamp.bed_temper, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic pushall commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.on_bed_cooled toggle and all 7 queue event toggles (on_queue_job_added, on_queue_job_assigned, on_queue_job_started, on_queue_job_waiting, on_queue_job_skipped, on_queue_job_failed, on_queue_completed) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to false regardless of user selection. Editing an existing provider worked correctly.manual_start/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared.formatDateTime function didn't use parseUTCDate, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the shared parseUTCDate utility for correct UTC-to-local conversion.info field parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio uses std::stoull(str, nullptr, 16)), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses the info field, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (0xE), and merges partial updates into the existing map. Reported by @cadtoolbox.0500-C010 "MicroSD Card read/write exception" until Bambuddy was restarted. The FTP upload used transfercmd() for A1 compatibility but skipped reading the server's 226 "Transfer complete" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89.steel_rod motion system category and "Lubricate Steel Rods" / "Clean Steel Rods" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy.lastDispatchSummaryRef) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key ("first-complete:1:0"). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in "Processing" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts.sm: breakpoint that didn't account for actual card width. Added overflow-hidden to the flex buttons and truncate to the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166.enabled_at timestamp was stored using datetime.now() (local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC.screenshots entries required for Chrome's richer install UI. Resized all three icons (android-chrome-192x192.png, android-chrome-512x512.png, apple-touch-icon.png) to their declared sizes, split the discouraged "any maskable" purpose into a dedicated "maskable" entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert.status="completed" (actually printed via a printer) now count toward completion stats. Files with status="archived" (stored but not yet printed) are no longer included. Reported by @SebSeifert.ImportError: cannot import name 'StrEnum' from 'enum' because enum.StrEnum was added in Python 3.11. Added a compatibility shim that falls back to (str, Enum) on Python < 3.11, matching the documented requirement of Python 3.10+.SSLV3_ALERT_HANDSHAKE_FAILURE on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added AES256-GCM-SHA384 and AES128-GCM-SHA256 to the client SSL context's cipher list.CTRL_C_EVENT to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (CREATE_NEW_PROCESS_GROUP) so cleanup no longer affects the server. Reported by @Reactantvr.nozzle_id, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by nozzle_id, matching the single-printer behavior. Reported by @cadtoolbox.tray_sub_brands (e.g., "PLA Basic", "PLA Matte", "PETG HF") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includes tray_sub_brands in the dedup key, so "PLA Basic Black" and "PLA Matte Black" appear as separate entries instead of collapsing into duplicate "PLA (Black)" rows. Reported by @cadtoolbox..3mf (without .gcode in the name). The isSlicedFile() check only matched .gcode or .gcode.3mf extensions, but .3mf files can be either sliced (contains gcode) or raw source models. Now checks the archive's total_layers and print_time_seconds metadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename).parsePresetName() and backend _parse_material_from_name() now detect the "X Support for Y" naming pattern and extract the material after "Support for". The frontend also prefers the corrected parsed material over the stored filament_type (which may have been saved with the old parser during import).print_error values in MQTT during normal printing (e.g., 0x03000002 → short code 0300_0002). The print_error parser treated any non-zero value as a real error, appending it to hms_errors and triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >= 0x4000 (0x4xxx = fatal, 0x8xxx = warning/pause, 0xCxxx = prompt). Values below 0x4000 are status/phase indicators, not faults. Now skips values where the error portion is below 0x4000 in both the print_error and hms array parsers.WARNING greenlet_spawn has not been called; can't call await_only() here and silently failed. The Spool.assignments relationship was never eagerly loaded: when auto_assign_spool() created a new SpoolAssignment and called db.add(), SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitialized spool.assignments collection. The previous fix only covered spool.k_profiles. Now also initializes spool.assignments = [] on newly created spools in create_spool_from_tray(), and adds selectinload(Spool.assignments) to both queries in get_spool_by_tag() for existing spools. Added exc_info=True to the error handlers for full tracebacks in future logs.tag_uid but left tag_type and data_origin empty, because it called the generic updateSpool API instead of the dedicated linkTagToSpool endpoint. The printer card's LinkSpoolModal already used linkTagToSpool correctly. Now uses linkTagToSpool with tag_type: 'generic' and data_origin: 'nfc_link', which also handles conflict checks and archived tag recycling.remain field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from (label_weight - weight_used) / label_weight, falling back to AMS remain when no inventory assignment exists.id=255, and the idle sentinel tray_now=255 matched it via trayNow === extTrayId. The main printer card avoided this by clearing effectiveTrayNow to undefined when tray_now=255. Now guards against tray_now=255 before any ext slot active check.state === 'RUNNING' but not 'PAUSE', even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both RUNNING and PAUSE states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.assign_spool backend called the cloud API with the raw slicer_filament value including its version suffix (e.g., PFUS9ac902733670a9_07), which returned a 404; the silent fallback sent the setting_id as tray_info_idx instead of the real filament_id (e.g., PFUS9ac902733670a9 instead of P4d64437), and the slicer couldn't resolve the preset; (2) no SlotPresetMapping was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real filament_id via the cloud API (with local preset and generic ID fallbacks), includes the brand name in tray_sub_brands, and saves the slot preset mapping from the frontend after assignment."aborted" on the queue item, but the response schema only accepts "pending", "printing", "completed", "failed", "skipped", or "cancelled". Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises "aborted" to "cancelled" before storing. A startup fixup also converts any existing "aborted" rows.on_print_complete(status="completed") created background asyncio tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, async_session was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.sync_from_db() skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.archives:read, queue:read, library:read). The Printers item remains always visible as the home page. Also added the missing inventory:read|create|update|delete permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).camera:view permission. Now disabled with a permission tooltip, matching the existing pattern for printers:control on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The stop_camera_stream endpoint called terminate() but never wait()ed or kill()ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses terminate() → wait(2s) → kill() → wait(); (2) each stream gets a background disconnect monitor task that polls request.is_disconnected() every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans /proc for any ffmpeg process with a Bambu RTSP URL (rtsps://bblp:) that isn't in an active stream and SIGKILLs it — catching orphans that survive app restarts or generator abandonment.start_bambuddy.bat Python hash verification used a multi-line for /f "usebackq" with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimited for /f commands, causing "The syntax of the command is incorrect" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, but verify_sha256 had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded the amd64 checksum even on arm64 systems.PrinterQueueWidget (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.nfc_reader.py and scale_reader.py import read_tag and scale_diag as bare modules, but these files live in spoolbuddy/scripts/ which isn't on Python's module search path. The systemd service sets WorkingDirectory to spoolbuddy/ and runs python -m daemon.main, so only the spoolbuddy/ and daemon/ directories are on sys.path. Added scripts/ to sys.path at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the read_tag import inside NFCReader.__init__'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.tare command via heartbeat but never called scale.tare() — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called scale.update_calibration(), so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the set-factor endpoint computed calibration_factor using the DB tare_offset, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a POST /devices/{device_id}/calibration/set-tare endpoint and update_tare() API client method. The heartbeat loop now executes scale.tare() when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard._on_message handler called msg.payload.decode() (strict UTF-8), and the resulting UnicodeDecodeError was not caught — only json.JSONDecodeError was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches UnicodeDecodeError and falls back to decode(errors="replace"), which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.O1C2 via MQTT, but only O1C was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added O1C2 to all model ID maps across backend and frontend.192.168.192.0/24); now masks the first two octets (x.x.192.0/24). Second, network_mode_hint used len(interfaces) > 2 which always reported "bridge" on single-NIC hosts even with network_mode: host, because get_network_interfaces() excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (docker0, br-*, veth*) via socket.if_nameindex() — these are only visible when the container shares the host network namespace. Third, developer_mode was still null for most users because the MQTT fun field was only parsed inside the print key; some firmware versions send it at the top level of the payload. Now also checks top-level fun. Also added a virtual_printers section with mode, model, enabled/running status, and pending file count for each configured virtual printer.Path.iterdir() returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could pick eth0 (MAC ending 3100) or wlan0 (MAC ending 3102), producing a different device_id each time. Since calibration values (tare_offset, calibration_factor) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen.activate_type_a() call that returned None (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.ci.yml, security.yml) from Node.js 20 to Node.js 22 LTS ahead of GitHub's Node 20 deprecation.0.2.2b4-daily.20260313) as full releases instead of betas, because the -daily.YYYYMMDD suffix pushed the last dot-segment to a pure number (20260313), bypassing the prerelease detection. Users running the same beta version saw a spurious "update available" notification after each daily build. Now strips the daily suffix before parsing.inventory:view_assignments permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required inventory:read, which also exposed the full Inventory page in the sidebar. Admins can now grant inventory:view_assignments without inventory:read so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.bambuddy_build_info gauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus _build_info convention for dashboards and version-change alerting. Contributed by @sw1nn.t() calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (PROVIDER_LABELS, EVENT_LABELS, CATEGORY_LABELS) in favor of dynamic translation key lookups with fallbacks.install.sh) now supports a --branch option and an interactive branch prompt (defaults to main). Previously the script hardcoded origin/main, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs use git clone --branch, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a "(beta)" label. Invalid branch names are caught early with an error message listing available branches.language field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs i18n.changeLanguage(). The top bar clock uses formatTimeOnly() with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).--overscroll-history-navigation=0) in the install script to prevent accidental back-navigation on the touchscreen. Added the video group to the SpoolBuddy system user for DSI backlight access.home_flag bit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details.AmsUnitCard component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single assignSpool API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.backend/app/utils/filament_ids.py). The assign_spool endpoint now normalizes slicer_filament (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct tray_info_idx and setting_id for the MQTT command. Previously setting_id was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.slicer_binary_path from earlier slicer integration research).voidresp() wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.dangerouslySetInnerHTML without sanitization, allowing injected <script> or event handler payloads to execute in any viewer's browser and steal JWT tokens from localStorage. Now sanitized with DOMPurify before rendering.<a> tags by interpolating the href attribute without escaping embedded quotes. A crafted 3MF file with a single-quoted href containing a double-quote break-out could inject onmouseover event handlers through the sanitizer. Replaced the custom sanitizer with DOMPurify./api/v1/auth/setup endpoint could be called without authentication even when auth was already enabled, allowing any network client to disable authentication entirely. Now returns 403 when auth is already enabled; use the authenticated admin panel to modify auth settings.rtsps:// URLs and added access codes to the sensitive string collection for exact-match redaction.manual_start set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.{eta} template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing {estimated_time} still shows duration ("1h 23m"). Requested by @SebSeifert.inventory:view_assignments permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required inventory:read, which also exposed the full Inventory page in the sidebar. Admins can now grant inventory:view_assignments without inventory:read so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.bambuddy_build_info gauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus _build_info convention for dashboards and version-change alerting. Contributed by @sw1nn.v0.2.3b1-daily.20260316) were offered as updates even with "Include beta versions" toggled off. The version parser only checked the last dot-separated segment for prerelease markers, but daily build tags put the beta indicator (b1) earlier with a numeric date suffix as the last segment. Now checks the entire version string. Reported by @Teolhyn.GET /api/v1/support/debug-logging endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive datetime.now(), raising TypeError. Now strips timezone info when reading the stored timestamp.bed_temper, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic pushall commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.on_bed_cooled toggle and all 7 queue event toggles (on_queue_job_added, on_queue_job_assigned, on_queue_job_started, on_queue_job_waiting, on_queue_job_skipped, on_queue_job_failed, on_queue_completed) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to false regardless of user selection. Editing an existing provider worked correctly.manual_start/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared.formatDateTime function didn't use parseUTCDate, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the shared parseUTCDate utility for correct UTC-to-local conversion.info field parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio uses std::stoull(str, nullptr, 16)), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses the info field, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (0xE), and merges partial updates into the existing map. Reported by @cadtoolbox.0500-C010 "MicroSD Card read/write exception" until Bambuddy was restarted. The FTP upload used transfercmd() for A1 compatibility but skipped reading the server's 226 "Transfer complete" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89.steel_rod motion system category and "Lubricate Steel Rods" / "Clean Steel Rods" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy.lastDispatchSummaryRef) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key ("first-complete:1:0"). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in "Processing" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts.sm: breakpoint that didn't account for actual card width. Added overflow-hidden to the flex buttons and truncate to the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166.enabled_at timestamp was stored using datetime.now() (local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC.t() calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (PROVIDER_LABELS, EVENT_LABELS, CATEGORY_LABELS) in favor of dynamic translation key lookups with fallbacks.install.sh) now supports a --branch option and an interactive branch prompt (defaults to main). Previously the script hardcoded origin/main, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs use git clone --branch, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a "(beta)" label. Invalid branch names are caught early with an error message listing available branches.screenshots entries required for Chrome's richer install UI. Resized all three icons (android-chrome-192x192.png, android-chrome-512x512.png, apple-touch-icon.png) to their declared sizes, split the discouraged "any maskable" purpose into a dedicated "maskable" entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert.status="completed" (actually printed via a printer) now count toward completion stats. Files with status="archived" (stored but not yet printed) are no longer included. Reported by @SebSeifert.ImportError: cannot import name 'StrEnum' from 'enum' because enum.StrEnum was added in Python 3.11. Added a compatibility shim that falls back to (str, Enum) on Python < 3.11, matching the documented requirement of Python 3.10+.SSLV3_ALERT_HANDSHAKE_FAILURE on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added AES256-GCM-SHA384 and AES128-GCM-SHA256 to the client SSL context's cipher list.CTRL_C_EVENT to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (CREATE_NEW_PROCESS_GROUP) so cleanup no longer affects the server. Reported by @Reactantvr.nozzle_id, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by nozzle_id, matching the single-printer behavior. Reported by @cadtoolbox.tray_sub_brands (e.g., "PLA Basic", "PLA Matte", "PETG HF") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includes tray_sub_brands in the dedup key, so "PLA Basic Black" and "PLA Matte Black" appear as separate entries instead of collapsing into duplicate "PLA (Black)" rows. Reported by @cadtoolbox.language field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs i18n.changeLanguage(). The top bar clock uses formatTimeOnly() with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).--overscroll-history-navigation=0) in the install script to prevent accidental back-navigation on the touchscreen. Added the video group to the SpoolBuddy system user for DSI backlight access.home_flag bit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details./spoolbuddy/write-tag) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type application/opentag3d, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with data_origin=opentag3d. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.react-simple-keyboard with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with data-vkb="false" are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard./spoolbuddy/ams) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the ConfigureAmsSlotModal to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.ProtectedRoute requires a user object from GET /auth/me, which only accepted JWT tokens. The /auth/me endpoint now also accepts API keys (via Authorization: Bearer bb_xxx or X-API-Key header) and returns a synthetic admin user with all permissions. The frontend's AuthContext reads an optional ?token= URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (/spoolbuddy?token=${API_KEY}), so the device authenticates automatically on boot without manual login.docker-publish-daily-beta.sh) that reads the current APP_VERSION from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., 0.2.2b1) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as latest.last_scale_weight and last_weighed_at on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this..3mf (without .gcode in the name). The isSlicedFile() check only matched .gcode or .gcode.3mf extensions, but .3mf files can be either sliced (contains gcode) or raw source models. Now checks the archive's total_layers and print_time_seconds metadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename).parsePresetName() and backend _parse_material_from_name() now detect the "X Support for Y" naming pattern and extract the material after "Support for". The frontend also prefers the corrected parsed material over the stored filament_type (which may have been saved with the old parser during import).print_error values in MQTT during normal printing (e.g., 0x03000002 → short code 0300_0002). The print_error parser treated any non-zero value as a real error, appending it to hms_errors and triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >= 0x4000 (0x4xxx = fatal, 0x8xxx = warning/pause, 0xCxxx = prompt). Values below 0x4000 are status/phase indicators, not faults. Now skips values where the error portion is below 0x4000 in both the print_error and hms array parsers.WARNING greenlet_spawn has not been called; can't call await_only() here and silently failed. The Spool.assignments relationship was never eagerly loaded: when auto_assign_spool() created a new SpoolAssignment and called db.add(), SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitialized spool.assignments collection. The previous fix only covered spool.k_profiles. Now also initializes spool.assignments = [] on newly created spools in create_spool_from_tray(), and adds selectinload(Spool.assignments) to both queries in get_spool_by_tag() for existing spools. Added exc_info=True to the error handlers for full tracebacks in future logs.tag_uid but left tag_type and data_origin empty, because it called the generic updateSpool API instead of the dedicated linkTagToSpool endpoint. The printer card's LinkSpoolModal already used linkTagToSpool correctly. Now uses linkTagToSpool with tag_type: 'generic' and data_origin: 'nfc_link', which also handles conflict checks and archived tag recycling.remain field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from (label_weight - weight_used) / label_weight, falling back to AMS remain when no inventory assignment exists.id=255, and the idle sentinel tray_now=255 matched it via trayNow === extTrayId. The main printer card avoided this by clearing effectiveTrayNow to undefined when tray_now=255. Now guards against tray_now=255 before any ext slot active check.state === 'RUNNING' but not 'PAUSE', even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both RUNNING and PAUSE states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.assign_spool backend called the cloud API with the raw slicer_filament value including its version suffix (e.g., PFUS9ac902733670a9_07), which returned a 404; the silent fallback sent the setting_id as tray_info_idx instead of the real filament_id (e.g., PFUS9ac902733670a9 instead of P4d64437), and the slicer couldn't resolve the preset; (2) no SlotPresetMapping was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real filament_id via the cloud API (with local preset and generic ID fallbacks), includes the brand name in tray_sub_brands, and saves the slot preset mapping from the frontend after assignment."aborted" on the queue item, but the response schema only accepts "pending", "printing", "completed", "failed", "skipped", or "cancelled". Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises "aborted" to "cancelled" before storing. A startup fixup also converts any existing "aborted" rows.on_print_complete(status="completed") created background asyncio tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, async_session was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.sync_from_db() skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.archives:read, queue:read, library:read). The Printers item remains always visible as the home page. Also added the missing inventory:read|create|update|delete permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).camera:view permission. Now disabled with a permission tooltip, matching the existing pattern for printers:control on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The stop_camera_stream endpoint called terminate() but never wait()ed or kill()ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses terminate() → wait(2s) → kill() → wait(); (2) each stream gets a background disconnect monitor task that polls request.is_disconnected() every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans /proc for any ffmpeg process with a Bambu RTSP URL (rtsps://bblp:) that isn't in an active stream and SIGKILLs it — catching orphans that survive app restarts or generator abandonment.start_bambuddy.bat Python hash verification used a multi-line for /f "usebackq" with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimited for /f commands, causing "The syntax of the command is incorrect" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, but verify_sha256 had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded the amd64 checksum even on arm64 systems.PrinterQueueWidget (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.nfc_reader.py and scale_reader.py import read_tag and scale_diag as bare modules, but these files live in spoolbuddy/scripts/ which isn't on Python's module search path. The systemd service sets WorkingDirectory to spoolbuddy/ and runs python -m daemon.main, so only the spoolbuddy/ and daemon/ directories are on sys.path. Added scripts/ to sys.path at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the read_tag import inside NFCReader.__init__'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.tare command via heartbeat but never called scale.tare() — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called scale.update_calibration(), so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the set-factor endpoint computed calibration_factor using the DB tare_offset, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a POST /devices/{device_id}/calibration/set-tare endpoint and update_tare() API client method. The heartbeat loop now executes scale.tare() when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard._on_message handler called msg.payload.decode() (strict UTF-8), and the resulting UnicodeDecodeError was not caught — only json.JSONDecodeError was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches UnicodeDecodeError and falls back to decode(errors="replace"), which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.O1C2 via MQTT, but only O1C was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added O1C2 to all model ID maps across backend and frontend.192.168.192.0/24); now masks the first two octets (x.x.192.0/24). Second, network_mode_hint used len(interfaces) > 2 which always reported "bridge" on single-NIC hosts even with network_mode: host, because get_network_interfaces() excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (docker0, br-*, veth*) via socket.if_nameindex() — these are only visible when the container shares the host network namespace. Third, developer_mode was still null for most users because the MQTT fun field was only parsed inside the print key; some firmware versions send it at the top level of the payload. Now also checks top-level fun. Also added a virtual_printers section with mode, model, enabled/running status, and pending file count for each configured virtual printer.Path.iterdir() returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could pick eth0 (MAC ending 3100) or wlan0 (MAC ending 3102), producing a different device_id each time. Since calibration values (tare_offset, calibration_factor) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen.activate_type_a() call that returned None (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.AmsUnitCard component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single assignSpool API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.backend/app/utils/filament_ids.py). The assign_spool endpoint now normalizes slicer_filament (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct tray_info_idx and setting_id for the MQTT command. Previously setting_id was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.slicer_binary_path from earlier slicer integration research).voidresp() wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.O1C2 via MQTT, but only O1C was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added O1C2 to all model ID maps across backend and frontend.archives:read, queue:read, library:read). The Printers item remains always visible as the home page. Also added the missing inventory:read|create|update|delete permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).camera:view permission. Now disabled with a permission tooltip, matching the existing pattern for printers:control on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The stop_camera_stream endpoint called terminate() but never wait()ed or kill()ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses terminate() → wait(2s) → kill() → wait(); (2) each stream gets a background disconnect monitor task that polls request.is_disconnected() every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans /proc for any ffmpeg process with a Bambu RTSP URL (rtsps://bblp:) that isn't in an active stream and SIGKILLs it — catching orphans that survive app restarts or generator abandonment.start_bambuddy.bat launcher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config has core.autocrlf=false or input, the file is checked out with LF endings and cmd.exe cannot parse it. Added a .gitattributes file that forces CRLF for all .bat files regardless of git config.PrinterQueueWidget (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count._on_message handler called msg.payload.decode() (strict UTF-8), and the resulting UnicodeDecodeError was not caught — only json.JSONDecodeError was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches UnicodeDecodeError and falls back to decode(errors="replace"), which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.datetime.now(timezone.utc)) produced aware datetimes, but SQLAlchemy's SQLite DateTime columns return naive datetimes on read. Any Python-side comparison between the two raised TypeError: can't subtract offset-naive and offset-aware datetimes, crashing the maintenance overview endpoint and potentially 7 other code paths (API key expiration, smart plug auto-off, power alert cooldown, runtime tracking, print scheduling, and timelapse matching). Added tzinfo is None guards before all database datetime comparisons.cap_add: NET_BIND_SERVICE in docker-compose.yml didn't reliably propagate to the Python process when running as a non-root user (user: directive), depending on the container runtime's ambient capability support. Now sets the file capability directly on the Python binary in the Dockerfile via setcap, which the kernel honors regardless of runtime configuration.dataMin/dataMax), not the selected time window. When the printer was offline for part of the period, shorter views (e.g., 6h) appeared compressed to only the portion with data (e.g., 1.5h). Now pins the X axis domain to the full requested time range (e.g., now−6h to now), pads the data edges so the line extends across the full window, and connects through null values so the chart always shows a continuous line.PrinterQueueWidget only checked required_filament_types (type only) and ignored filament_overrides (type + color). Now passes loaded filament type+color pairs from AMS/vt_tray status to the widget and filters queue items against override colors, mirroring the backend's _count_override_color_matches() logic.-wal file, but the shutdown handler never checkpointed the WAL back into the main database or disposed of engine connections. If the container was stopped or crashed, the WAL could contain partial schema migrations or uncommitted data, causing inconsistent query results on restart. Deleting the -wal and -shm files was the only workaround. Now runs PRAGMA wal_checkpoint(TRUNCATE) and disposes the engine on shutdown, ensuring all data is flushed to the main database file before exit.plate_id was always 1, generating the wrong MQTT gcode path for multi-plate 3MF files (HMS error 0500_4003). Now extracts the plate index from the 3MF's slice_info.config. Second, ams_mapping was never computed for printer-specific queue items (VP assigned to a particular printer), so the printer always used the first AMS slot regardless of which filament the 3MF required. The scheduler now computes AMS mapping for all queue items that lack one, not just model-based assignments.PrinterQueueWidget now filters queue items by filament compatibility — it checks the printer's loaded filament types (from AMS and external spools) against the queue item's required_filament_types and only shows items the printer can actually print. If no compatible items exist, the widget is hidden.weight_locked flag that is automatically set when weight_used is explicitly updated via the API. Locked spools are skipped by both the automatic AMS remain% sync and the manual force-sync endpoint. The usage tracker (3MF/gcode delta tracking) is unaffected. Users can re-enable AMS sync by setting weight_locked: false.archive.cost with conflicting strategies: the usage tracker summed ALL historical SpoolUsageHistory rows for the archive (including rows from previous reprints), and a separate add_reprint_cost method added yet another full print's cost on top. Removed the redundant add_reprint_cost path entirely and changed the usage tracker to compute cost only from the current print session's results instead of querying all historical rows. archive.cost now always reflects the cost of a single print.datetime.now() (server local time) or the deprecated datetime.utcnow(). The frontend's parseUTCDate() assumes timestamps without timezone indicators are UTC and appends 'Z', so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with datetime.now(timezone.utc) across 16 backend files (~80 call sites). On the frontend, replaced 13 new Date(backendTimestamp) calls with parseUTCDate() across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time.printers:control permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed.role === 'user' check instead of the actual settings:read permission, so newly created admin users who had the permission still couldn't see the button. Also, after login the auth state was set directly from the login response instead of re-fetching the full auth status, which could miss permission data. Now uses hasPermission('settings:read') for the sidebar check and calls checkAuthStatus() after login to load the complete user state including permissions./, \, ?, or # (e.g., Abzweigdose/Verteilerdose 70mm) caused the slicer protocol handler to fail. The filename is placed in the download URL path and encodeURIComponent-encoded, but BambuStudio and OrcaSlicer call url_decode() on the entire protocol handler URL before downloading. This decoded %2F back to /, creating extra path segments that resulted in a 404. The URL filename is purely cosmetic (the backend resolves files by archive ID, not filename), so now sanitizes /, \, ?, and # to _ in slicer download URLs._find_idle_printer_for_model() validated only filament type (via _get_missing_filament_types()), while color matching (_count_override_color_matches()) was used only for ranking candidates, not filtering them. A printer with 0 color matches was still selected if it had the right types. Now requires at least 1 color match when filament overrides specify colors — printers with 0 matches are skipped and added to the "waiting for filament" reason instead of being treated as valid candidates._add_to_print_queue() method always created queue items with printer_id=None and no target_model. Now assigns the virtual printer's target_printer_id if configured, or falls back to the VP's model (e.g., P1S, X1C) as target_model for "Any Printer" scheduling.onSuccess handler overwrote localSettings with the server response, discarding characters typed during the save request. Removed the stale state overwrite so in-progress user input is preserved.GET /api/v1/queue and GET /api/v1/queue/{id} endpoints now include filament_type, filament_color, layer_height, nozzle_diameter, and sliced_for_model from the archive or library file. Previously these fields were only available via the archive endpoints, requiring an extra API call.max-w-lg to max-w-xl to give profile names more room.on_print_complete callback, after an early return that exits when no archive is found for the print. Prints started from BambuStudio or the printer's touchscreen typically have no archive in Bambuddy, so the function returned before the bed cooldown task was ever created. Moved the bed cooldown monitor to before the archive lookup early-return so it fires for all completed prints regardless of archive state. Also hardened the temperature dict check from truthiness (if status.temperatures:) to type check (isinstance(status.temperatures, dict)) to avoid false negatives on empty dicts._sanitize_log_content() function redacted emails, serials, and credentials but left raw IPv4 addresses in log output. Now adds known printer IPs to the sensitive string list for exact matching, and applies an IPv4 regex that replaces addresses with [IP] while preserving firmware version strings (which use leading-zero octets like 01.09.01.00). Updated the system info page privacy disclaimer to list IP addresses as redacted.stg_cur=74 during print preparation, but this stage was not in the stage name lookup table (which went up to 66, sourced from BambuStudio). Now maps stage 74 to "Preparing". Also added stage 77 ("Preparing AMS") which was present in BambuStudio but missing from the lookup.tray_now=254 generically for both external spools, so the frontend's direct ID comparison (effectiveTrayNow === extTrayId) always matched Ext-L (id=254). Now uses active_extruder on dual-nozzle printers to determine which external spool is active: extruder 1 (left) → Ext-L, extruder 0 (right) → Ext-R.on_ams_change stale-assignment cleanup searched only AMS unit data for matching trays, but external spools live in vt_tray (a separate MQTT field). Since _find_tray_in_ams_data never found them, external assignments were always marked as stale and removed. Now looks up external spool assignments (ams_id=255) in the printer's vt_tray data instead, and keeps the assignment if vt_tray data hasn't arrived yet.fun field is an integer in the JSON payload, but the parser used int(value, 16) which requires a string argument. This raised TypeError on every message, silently caught by the exception handler, so developer_mode was never set. Now handles both integer and hex string formats.remain fallback entirely — extEffectiveFill only checked Spoolman and inventory, falling through to null even when the printer reported a valid fill percentage. Now includes the same AMS remain fallback as regular and AMS-HT slots. Second, when fill level was unknown (null), the AMS slot visual showed a full-width gray bar (appearing "full") while the hover card showed "—" (appearing "empty") — confusing users into thinking the printer card and hover card disagreed. Removed the misleading gray fallback bar from all three slot types; the empty fill bar track now consistently indicates "unknown" in both views. Third, the fill level priority chain always preferred AMS remain over Spoolman and inventory data, even when those sources were more accurate (e.g., spools migrated from Spoolman to internal inventory, or spools with accurate usage tracking). Reversed the priority to Spoolman → Inventory → AMS remain, and fixed fillSource to correctly reflect the actual data source used (was always reporting 'ams' even when Spoolman or inventory provided the value via the fallback chain when remain was -1).filename field but not file_metadata.print_name, which the UI uses as the primary display name. Since print_name is extracted from inside the 3MF at upload time, it always took precedence over the renamed filename. The rename endpoint now also updates print_name in the file metadata when present.file_path was null. The finish photo capture silently skipped because it derived the save directory from file_path. Now falls back to archive/{id}/ so the photo is captured regardless.GET /printers/available-filaments endpoint aggregates loaded filaments across all active printers of a given model. Backend stores overrides as a JSON column on the queue item and applies them at scheduling time by merging into filament requirements before AMS mapping. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).tray_now, not global tray IDs — contrary to the previous assumption that all single-nozzle printers report global IDs. Filament usage tracking was unaffected because it uses the MQTT mapping field (snow-encoded with correct AMS hardware IDs). The display now cross-references tray_now with the MQTT mapping field to resolve the correct AMS unit when multiple AMS units are detected via ams_exist_bits. Falls back to the raw value when no mapping is available (e.g., manual filament load outside of a print) or when the mapping is ambiguous.indexOf), so "PCTG" matched "PC" first. The AMS slot configuration and local profiles views were also missing PCTG from their known material types. Additionally, the temperature range logic used includes('PC') which matched PCTG and assigned PC temperatures (260-300°C) instead of PETG-range temperatures (220-260°C). Fixed by reordering PCTG before PC in the spool form parser, adding PCTG to all material type arrays, and adding an exact-match temperature case for PCTG.delete_file_async catches errors internally and returns False instead of raising — the except retry branch never executed. Fixed by only breaking on successful delete and retrying with a 2-second delay on failure. Second, when start_print() failed after uploading a file (in both the background dispatcher and print scheduler), the uploaded file was never cleaned up since on_print_complete never fires for a print that never started. Now deletes the uploaded file on a best-effort basis when start_print() returns False. Third, cleanup failure logging was at DEBUG level, making failures invisible in normal operation — escalated to WARNING.0500_0007 "MQTT command verification failed") were triggering printer error notifications even though they don't indicate actual print problems. For example, a device with incorrect bind settings sending unauthorized MQTT commands caused repeated false-alarm nozzle/extruder error notifications with camera snapshots of perfectly fine prints. Now suppresses notifications for known non-actionable error codes: 0500_0007 (MQTT auth failure), 0500_4001 (Bambu Cloud connection failure), and 0500_400E (print cancelled by user).http://user:pass@host) were logged verbatim by httpx; now uses httpx's auth parameter for HTTP Basic auth so credentials never appear in the URL. Added username and path to the settings key filter to redact smtp_username and slicer_binary_path from the support info JSON. A URL credentials regex provides defense-in-depth for any remaining user:pass@ patterns in logs. IP addresses are no longer redacted from the bundle as they are needed for connectivity debugging. Updated the frontend privacy disclaimer and wiki documentation to reflect the new behavior.on_ams_change handler eagerly deleted the empty spool's SpoolAssignment record (fingerprint mismatch), so on_print_complete found nothing and silently dropped usage — fixed by snapshotting all spool assignments at print start into the PrintSession. Second, even with the snapshot fix, the entire print's filament weight was attributed to the original spool (100%/0% split) because _track_from_3mf() only knew about the tray loaded at print start. Now tracks tray changes during the print via tray_change_log on PrinterState, recording each tray switch with its layer number. At print completion, the usage tracker splits the 3MF weight across trays using per-layer gcode data for precise segment boundaries, with a linear layer-ratio fallback when gcode data isn't available. The last segment always receives the remainder to prevent rounding drift.AttributeError: 'NoneType' object has no attribute 'set'. The MQTT callback thread checked self._pending_kprofile_response (not None) at line 2698, but between that check and the .set() call, the asyncio thread's finally block in get_kprofiles() could clear the attribute to None after a timeout — a classic TOCTOU race. Fixed by capturing the event reference in a local variable before the check.printer_id=NULL and target_model="P1S". After the assigned printer finished, the queue widget queried only for items matching printer_id=X, missing the next pending model-based item (printer_id IS NULL). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional target_model parameter; when combined with printer_id, it uses OR logic to also return unassigned items whose target_model matches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't provide target_model (e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model.'>=' not supported between instances of 'str' and 'int' when computing AMS filament mapping. MQTT raw data returns AMS unit and tray IDs as strings, but _build_loaded_filaments() compared them to integers without casting. The crash prevented the assignment from committing, so the scheduler retried every 30 seconds in an infinite loop. Cast ams_id and tray_id to int() to match the pattern already used for external spool IDs.printer_manager.get_printer(), which returns a PrinterInfo with only name and serial_number. Accessing .ip_address, .access_code, and .model raised AttributeError, silently caught by the outer exception handler. Replaced with a DB query for the Printer model, matching the pattern used everywhere else in on_print_complete().file_path. The finish photo was saved correctly to data/photos/, but the photo serving endpoint resolved the path as (base_dir / "").parent / "photos/" which evaluates to base_dir.parent/photos/ — one directory level too high. The photo existed on disk but the API returned 404. Fixed the path resolution in get_photo, upload_photo, and delete_photo to use base_dir / Path(file_path).parent (same pattern as the save code), which correctly resolves to base_dir/photos/ when file_path is empty.file_path="". The archive endpoints used Path.exists() to check if the 3MF file was available, but settings.base_dir / "" resolves to the base directory itself — which exists() reports as True. Subsequent ZipFile() calls then failed with [Errno 21] Is a directory. Replaced all .exists() checks on archive file paths with .is_file() across 15 locations in the archive routes and 1 in the main module. Also added a file_path truthiness guard for finish photo capture to prevent saving photos under the base directory when the archive has no file path.slicer_filament if set (including PFUS/P custom presets), (2) reuse slot's existing preset only if it's a specific non-generic ID for the same material, (3) generic Bambu filament ID as last resort. Both assign_spool and configure_ams_slot code paths are fixed.Message header. Multi-line messages (e.g., printer name + remaining time) contain newline characters, which are illegal in HTTP headers. Test notifications worked because they are single-line with no image. Now escapes newlines to literal \n in the header, which ntfy interprets and renders as actual line breaks. Additionally, ntfy servers with attachments disabled rejected thumbnail uploads with "attachments not allowed" (HTTP 400 / code 40014), causing the entire notification to fail. Now automatically retries without the image when the server doesn't support attachments.formatDate() that hardcoded the en-GB locale, always displaying dates in a fixed format regardless of the date format setting. Now fetches the date_format setting and uses the shared formatDateInput() utility which formats as MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or browser locale based on the user's choice.String.fromCharCode(65 + ams_id), which produced accented characters (e.g., Á) for AMS-HT units (ams_id ≥ 128). Now uses the shared formatSlotLabel() utility which correctly handles AMS-HT and external spool slots.POST /inventory/spools/bulk. Stock spools are computed (no database migration) — any spool without a slicer_filament is displayed with an amber "Stock" badge. A new filter (All / Stock / Configured) on the inventory page lets you filter by stock status. Group similar spools: a "Group" toggle in the inventory toolbar visually collapses identical unused/unassigned spools into a single expandable row or card with a count badge (e.g., "5 identical spools"). Grouping key uses material, subtype, brand, color, and label weight. Used or AMS-assigned spools always appear individually. Group state persists to localStorage. The Stock column is available but hidden by default in column settings. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).cost_per_kg value; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A global default_filament_cost setting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02./releases instead of /releases/latest and filters by parse_version() prerelease detection (not GitHub's prerelease flag, which may not be set correctly). Users on the Docker latest tag will no longer see notifications for beta releases they can't install.fun field (bit 0x20000000). When any connected printer lacks developer mode, a persistent orange warning banner appears at the top of the UI with the affected printer name(s) and a link to Bambu Lab's documentation on how to enable it. Without developer mode, MQTT write operations (start/stop/pause prints, AMS control, light/speed/gcode commands) are silently rejected by newer firmware. The developer_mode state is included in the support bundle for diagnostics. New /printers/developer-mode-warnings endpoint provides a lightweight polling summary. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR)./inventory route and sidebar position._count_override_color_matches (no status, exact match, no match, partial match, color normalization, external spool) and 5 for override application in filament matching (color override, tray_info_idx clearing, type change, partial override, nozzle filtering with override). Added 12 frontend tests for the FilamentOverride component: 5 rendering tests (null guards, slot display, dropdown count), 2 type filtering tests (same-type only, all colors), 3 nozzle filtering tests (extruder_id matching, single-nozzle passthrough, null extruder_id inclusion), and 2 interaction tests (select override, reset to original)._resolve_local_slot_from_mapping (snow decoding, unmapped entry filtering, ambiguity detection, AMS-HT slot matching). All 66 tray_now-related tests pass.SpoolBulkCreate schema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 29 frontend tests: 13 for SpoolFormModal covering validateForm with quickAdd flag (6 tests), quick-add toggle visibility, PA Profile tab hiding, quantity field gating (hidden by default, visible only in quick-add, hidden in edit mode), and brand/subtype optional asterisk removal in quick-add; 16 for inventory grouping logic covering spoolGroupKey identity/differentiation (7 tests) and computeDisplayItems grouping rules (9 tests for identical/different/used/assigned/single/order/mixed/empty scenarios).archive_id database migration, SQLAlchemy is None → .is_(None) in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.tray_change_log lifecycle (default empty, seed on print start, clear on new print, record during RUNNING/PAUSE, ignore during IDLE, deduplicate, multi-change history). Added 6 usage tracker unit tests for weight splitting (per-layer gcode split, linear fallback, no-change normal path, empty log recovery, missing spool skip, triple segment split).fun field parsing (bit clear/set detection, exact bit check, invalid hex handling, state persistence across messages). Added 4 frontend tests for the warning banner (single/multiple printer names, hidden when empty, "How to enable" link).frontend-typecheck (tsc --noEmit) and frontend-lint (eslint .) hooks to the pre-commit config. Both hooks only trigger when frontend/src/**/*.{ts,tsx} files are staged.PAUSED checks across frontend and backend. The printer only sends PAUSE via MQTT gcode_state, so PAUSED comparisons were unreachable code.extract_nozzle_mapping_from_3mf() function used filament_nozzle_map (user preference) as the primary source for nozzle assignments. BambuStudio's "Auto For Flush" mode overrides user preferences at slice time, so the actual assignment lives in the group_id attribute on <filament> elements in slice_info.config. Now uses group_id as the primary source and falls back to filament_nozzle_map only when group_id is not present./printers/{id}/status endpoint read ams_extruder_map from the MQTT state without checking if the AMS data had been received yet. On fresh connections before the first AMS push-all, this returned an empty map — causing the frontend nozzle filter to show all trays as unfiltered. Now returns an empty object gracefully and the frontend disables nozzle filtering until the map is populated.useFilamentMapping hook always set extruder_id: 0 for external spool matches. Now uses the nozzle mapping from the 3MF file to determine the correct extruder.ams_id * 4 + slot (giving 512+), but AMS-HT units use their raw ams_id (128-135) as the global tray ID. Now uses ams_id directly for AMS-HT units.extruder_id using strict equality, but extruder_id could be undefined for printers that hadn't reported their AMS extruder map yet. This caused all trays to be hidden. Now skips nozzle filtering when extruder_id is undefined.mc_percent and layer_num from the printer's MQTT state — but by the time the on_print_complete callback ran, the printer had already reset these to 0. Now captures the last valid progress and layer values during printing, and the usage tracker reads these captured values on cancellation for accurate partial usage.tray_now <= 3 check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent _is_dual_nozzle flag detected from device.extruder.info (>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report.snow_slot = -1 for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes snow_slot = 0 for AMS-HT single-slot units.ams_extruder_map fallback computed ams_id * 4 + slot for all AMS types — including AMS-HT units (IDs 128-135) which have a single slot and use their unit ID as the global tray ID. This produced bogus values like 512+ that briefly appeared in the UI and could pollute last_loaded_tray. Now correctly returns the AMS-HT unit ID for single-slot units, handles AMS-HT in multi-AMS matching, filters AMS-HT candidates when slot > 0, and tightens last_loaded_tray to only accept physically valid tray IDs (0-15, 128-135, 254).hover:z-20 and tooltip z-20 classes.Print Queue Shows UUID Hash Instead of Filename (#438) — When printing a library file, the Print Queue and archive displayed the UUID-hex disk filename (e.g., c65887535303404eba1525176a0f78dc) instead of the original human-readable name. Library files are stored on disk with UUID filenames for uniqueness, but archive_print() used the disk path as the display name. Now passes the original LibraryFile.filename through to archive_print() from both the print scheduler and the direct-print-from-library flow, so the archive's filename, print_name, and directory name all use the human-readable name.
Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers (#364) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT mapping field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to slot_id - 1 as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (ams_hw_id * 256 + local_slot) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on tray_now disambiguation. For printers that don't provide the MQTT mapping field (A1, A1 Mini, P1S, P2S), a color-matching fallback compares 3MF filament slot colors against AMS tray colors to resolve the correct slot-to-tray mapping. Gracefully returns no match when colors are ambiguous (duplicate tray colors) or unavailable.
AMS Slot Config: PFUS Preset IDs Cause Slicer to Reset Slots — When assigning a spool with a user-local PFUS* preset ID (from BambuStudio's custom filament profiles), the slicer didn't recognize the ID and actively reset the AMS slot configuration. Now replaces PFUS* IDs with generic Bambu filament IDs (e.g., GFL99 for PLA). When the slot already has a recognized cloud-synced preset for the same material (e.g., P4d64437), it is reused to preserve K-profile calibration associations. Applies to both the slot configure endpoint and the inventory spool assignment flow.
Fill Level Bar Missing for Brand New Spools — Spools with weight_used = 0 (brand new, never printed) showed no fill level bar on the printer card. The condition checked weight_used > 0 instead of weight_used != null, excluding zero-usage spools. Now correctly shows 100% fill for new spools while still hiding the bar when weight data is unavailable (null).
npm audit: suppress moderate ajv ReDoS finding — Added audit-level=high to frontend/.npmrc so npm audit exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted $data schema input — not an attack vector in a linting config.
npm audit: fix minimatch ReDoS finding — Added an npm override for minimatch@^10.2.1 in package.json to resolve the high-severity ReDoS (GHSA-3ppc-4f35-3m26) affecting minimatch@3.x/9.x pulled in transitively by eslint@9, typescript-eslint, and @vitest/coverage-v8. Eslint@9 pins minimatch@3.x with no patched release; eslint@10 upgrades to minimatch@10 but is not yet available. The override forces the patched version across the tree. Verified lint, build, and all tests pass.
Spool Form Allows Empty Brand & Subtype (#417) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the tray_sub_brands sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
Open in Slicer Fails When Authentication Enabled (#421) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (bambustudio://, orcaslicer://) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only bambustudioopen:// scheme instead of bambustudio://open?file=). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a /dl/{token}/{filename} URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses bambustudioopen:// with URL encoding, Windows/Linux use bambustudio://open?file=, and OrcaSlicer uses orcaslicer://open?file=.
/api/virtual-printers) and React UI for creating, editing, and deleting virtual printers. Each instance supports all four modes (Immediate, Review, Print Queue, Proxy), any of the 11 supported printer models, per-instance TLS certificates (shared CA), and individual network interface override. Database-backed with auto-incremented serial suffixes.printers:clear_plate permission allows admins to grant users the ability to confirm a plate is cleared for the next queued print without granting full printers:control (which also allows stopping prints, configuring AMS, toggling lights, etc.). Existing groups with printers:control automatically receive the new permission on startup. The Operators default group includes it by default./groups/:id/edit. Features a responsive 2-column grid of always-expanded category cards, permission search/filtering, Select All / Clear All bulk actions, category-level checkboxes with partial state, and a fixed bottom action bar. The old GroupsPage.tsx dead code has been removed./api/v1/filaments/ to /api/v1/filament-catalog/ to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.useFilamentMapping hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).tray_now disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, ams_extruder_map fallback, active extruder switching, and full multi-color print lifecycles.weight_used internally.used_g data from the archived 3MF file provides precise per-spool consumption. For failed or aborted prints, per-layer G-code analysis provides accurate partial usage up to the exact failure layer, with linear progress scaling as fallback. AMS remain% delta is the final fallback for G-code-only prints without an archived 3MF. Slot-to-tray mapping uses queue ams_mapping for queue-initiated prints and the printer's tray_now state for single-filament non-queue prints, ensuring the correct physical spool is always tracked.print_complete, print_failed, and print_stopped notification events now expose {filament_grams} (total grams, scaled by progress for partial prints), {filament_details} (per-filament breakdown with AMS slot info, e.g. "AMS-A T1 PLA: 12.4g | AMS-A T3 PETG: 2.8g"), and {progress} (completion percentage for failed/stopped prints). The {filament_details} variable includes the AMS unit and tray position for each filament used, with "Ext" shown for external spool holders. Falls back to type-only format (e.g. "PLA: 10.0g") when usage tracking data is unavailable. Webhook payloads include filament_used, filament_details, and progress fields. Per-slot filament data is stored in archive extra_data for downstream use.filament_nozzle_map + physical_extruder_map in project_settings.config) and constrains filament matching to only AMS trays connected to the correct nozzle via ams_extruder_map. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it).vt_tray field is now an array across the entire stack (MQTT, API, WebSocket, frontend).compatible_printers field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja)./cloud/filament-id-map endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field.print_log_entries database table.sendPhoto API with the image as caption attachment. ntfy sends the image as a binary PUT with Filename and Message headers. No configuration needed — images are sent automatically when available.clean_print_error MQTT command to dismiss stale print_error values that persist after print cancellation or transient events. Locally clears the error list for immediate UI feedback. Permission-gated to printers:control. The button only appears when there are active errors.X1C_01_09_00_10.bin) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file./updates/check endpoint also ignored the setting entirely. Now the backend returns early without making GitHub API calls when the setting is disabled, the Settings page respects the check_updates flag before auto-fetching, and the printer card firmware badge shows a neutral version-only display instead of disappearing when firmware update checks are off.SpoolAssignment records when enabling Spoolman, invalidates the frontend cache so printer cards update immediately, and hides the inventory assign/unassign UI on printer cards while in Spoolman mode.on_ams_change callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending ams_filament_setting without a setting_id, which cleared the printer's filament preset. Now compares spool RFID identifiers (tray_uuid / tag_uid) before unlinking — if the same spool is still in the slot, the assignment is preserved and no ams_filament_setting command is sent.is_bambu_lab_spool() function (backend) and isBambuLabSpool() (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). The tray_info_idx field (e.g., "GFA00") identifies the filament type, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed tray_info_idx from detection logic; now uses only hardware RFID identifiers (tray_uuid and tag_uid) which are physically embedded in genuine Bambu Lab spools.BambuFTPClient.disconnect() only caught OSError and ftplib.Error, but quit() raises EOFError when the server has closed the connection mid-session. EOFError is not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect.tag_uid and tray_uuid fields because they were included in the "always update" list. These fields are now preserved during updates and only cleared when a spool is physically removed (slot clearing detected by empty tray_type). This fixes the AMS "eye" icon disappearing for RFID spools after startup.ams_set_filament_setting, which replaced the firmware's RFID-managed filament config with a manual one — causing the slicer's "eye" icon to change to a "pen" icon. Now detects RFID spools and skips the filament setting command, only sending K-profile selection.extrusion_cali_sel command included a setting_id field that BambuStudio never sends, causing firmware to mislink calibration data. The extrusion_cali_set command was sent unconditionally, overwriting existing profile metadata. Now setting_id is removed from selection commands, and extrusion_cali_set is only sent when no existing profile is selected (cali_idx < 0).000000 (black) as a guard against empty slots, but empty slots already skip color data entirely. Removed the unnecessary check so black is now pre-populated like any other color.weight_used (e.g., +1.6g), but periodic AMS status updates recalculated weight_used from the AMS remain% sensor and overwrote the precise value. For small prints on large spools (e.g., 1.6g on 1000g), the AMS remain% stays at 100% (integer resolution = 10g steps), resetting weight_used back to 0. The AMS weight sync now only increases weight_used, never decreases it, preserving precise values from the usage tracker.remain=0 for all trays while tray_type is still populated. The weight sync treated 0% remain as "100% consumed," computing weight_used = label_weight (e.g., 1000g). The "only increase" guard passed because label_weight > current_used + 1, marking every assigned spool as fully consumed. The AMS weight sync now skips remain=0 entirely — a physically empty spool is tracked by the usage tracker during the print, not by a transient AMS sensor reading.weight_used. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset weight_used to the pre-print value, reverting the remaining weight to full. The form now only includes weight_used in the update request when the user explicitly changes the weight field.'SpoolKProfile' object has no attribute 'extruder_id'. The K-profile model uses extruder (not extruder_id). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.on_print_start callback used ilike('%{name}%') to find existing "printing" archives, which meant a print named "Clip" could incorrectly match "Cable Clip" or "Clip Stand". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exact print_name match or exact filename variants (.3mf, .gcode.3mf)..3mf files to the printer's SD card root (/) but never deleted them after the print finished. Some printers (e.g. P1S) auto-start files found in the root directory on power cycle, causing ghost prints on every reboot. Now deletes the uploaded file from the SD card after print completion (best-effort, non-blocking). The cleanup also tries .gcode files and retries up to 3 times with a 2-second delay to handle printers that briefly lock the filesystem after a print ends. Runs before the archive lookup so it works even when auto-archiving is disabled.printing to completed/failed) was placed after an early return that exits when the archive record cannot be found. If the archive lookup failed (e.g. app restart mid-print, manual archive deletion), the function returned early and the queue item stayed in printing forever. Over multiple print cycles, stale items accumulated — causing the "Printing" count to show double the actual printers and completed prints to remain in the "Currently Printing" section. Moved the queue item status update (including MQTT relay notification, queue-completed notification, and auto-power-off) to before the archive lookup early return so it always runs.overflow-y: auto, which on Windows Edge (where scrollbars take layout space) caused the scrollbar to appear and disappear on hover — making the color picker unusable at certain zoom levels. Added scrollbar-gutter: stable to reserve scrollbar space and prevent layout thrashing.on_print_start callback had to re-download the 3MF from the printer via FTP, and if that failed, a fallback archive was created without the 3MF file — making 3MF-based filament usage tracking impossible. The queue item's archive_id also remained NULL, so the usage tracker could not find the queue's AMS slot mapping for correct spool resolution. The scheduler now creates an archive from the library file before uploading, links it to the queue item, and registers it as an expected print — matching the behavior of the direct library print route.archive_name and archive_id when displaying the queued item name. Queue items from the file manager have library_file_name and library_file_id instead, so the widget displayed "Archive #null". Now falls back to library_file_name and library_file_id, matching the Queue page display logic.ams_mapping from reprint, library print, and queue print commands is now stored and used as the highest-priority mapping source for usage tracking.tray_now field is always 255 in MQTT data. The actual tray is resolved via the snow field ~44 seconds after print start, but reverts to "unloaded" when the AMS retracts filament at completion. The usage tracker now tracks last_loaded_tray — the last valid tray seen during printing — as a fallback when both tray_now at start and at completion are invalid. Also captures tray_now at print start for printers that report a valid value before the RUNNING state.ams_mapping the slicer sent, because it only subscribed to the printer's report topic. The usage tracker fell back to tray_now which could resolve to the wrong AMS tray (e.g., Black PLA at A2 instead of Green PLA at A4 on H2D Pro). Now subscribes to the MQTT request topic to intercept print commands from any source, capturing the ams_mapping universally — regardless of who starts the print. The request topic subscription is fail-safe: if the printer's MQTT broker rejects it (e.g., P1S), Bambuddy detects the rejection via SUBACK or disconnect timing and gracefully disables the subscription for that printer, falling back to the existing tray_now-based tracking without breaking the MQTT connection..avi (MJPEG), but the timelapse scanner only looked for .mp4 files — so P1S timelapses were never found or attached to archives. Now discovers both .mp4 and .avi timelapse files across all FTP directories (/timelapse, /timelapse/video, /record, /recording). AVI files are saved immediately and converted to MP4 in a non-blocking background task using FFmpeg with -threads 1 and nice -n 19 to minimize CPU impact on Raspberry Pi. If FFmpeg is unavailable, the AVI is served as-is with the correct MIME type. The manual "Scan for Timelapse" route also searches the additional directories used by P1-series printers..mp4, .avi, and .mkv files (non-MP4 auto-converted in background). Remove deletes the file and clears the database reference. Both actions are permission-gated and available in grid and list views.7CC4D5FF vs 56B7E6FF for the same spool, Euclidean distance ~43.6). Now uses a color similarity function with a tolerance threshold of 50, preventing false unlinks from minor RFID/firmware color variations while still detecting genuinely different spools.-p 3000:3000 -p 3002:3002).mapping field, tray_now, last_loaded_tray, all mapping-related raw data keys, and per-AMS-tray summaries (type, color, tray_now, tray_tar). Enables investigating the slot-to-tray mapping behavior across different printer models (X1E, H2D Pro, P1S, etc.) without requiring DEBUG mode.sqlalchemy.engine (changed from INFO to WARNING) and aiosqlite (new WARNING suppression) noise that previously filled 2.5MB in 16 minutes. Every start_print() call now logs a PRINT COMMAND trace with the caller's file, line, and function name. The print scheduler logs pending queue items when found. on_print_complete warns when multiple queue items are in "printing" status for the same printer, which signals a state inconsistency./camera/snapshot) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.file_1, file_2 instead of the original filename. The Content-Disposition header parser now handles RFC 5987 percent-encoded filenames (filename*=utf-8''...) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files).<img> URL was always the same (/printers/{id}/cover) regardless of which print was active, so the browser served its cached image. Now appends the print name as a cache-busting query parameter so the browser fetches the new cover when a different print starts.*Title* asterisks instead of bold text when the message body contained underscores (e.g. job name A1_plate_8, error code 0300_0001). The code was disabling Markdown parsing entirely when underscores were detected. Now escapes underscores in the body with \_ so Markdown rendering stays enabled.X-Frame-Options: SAMEORIGIN or CSP frame-ancestors headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.printers:control permission and is available in all supported languages (en/de/ja).plate_cleared flag is now included in the printer status API response, so the widget correctly shows the passive queue link instead of the Clear Plate button after acknowledgment — even after a page refresh.?tab=email URLs are handled automatically.confirm() dialog and archive had no confirmation at all. Delete shows a danger-styled modal, archive shows a warning-styled modal. Translated in all 5 locales (en, de, fr, it, ja).orcaslicer://open?file= protocol. Default remains Bambu Studio for backward compatibility..orca_filament, .bbscfg, .bbsflmt, .zip, and .json exports. Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL). Stores presets in the database with extracted core fields (material type, vendor, nozzle temps, pressure advance, compatible printers). New "Local Profiles" tab on the Profiles page with drag-and-drop import, 3-column layout (Filament/Process/Printer), search, and expandable preset details. Local filament presets appear in AMS slot configuration alongside cloud presets. Includes smart profile type detection (explicit type field, ZIP path hints, settings ID keys, content heuristics, and name-based patterns) and material/vendor extraction from preset names as fallback.printer.local, my-printer.home.lan) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.SkipObjectsModal component shared across PrintersPage and both camera views.tray_uuid over tag_uid for spool identification.HA_URL and HA_TOKEN environment variables for zero-configuration add-on deployments. Auto-enables when both variables are set. UI fields become read-only with lock icons when env-managed. Database values preserved as fallback.active_extruder, replacing the misleading "Docked" label.firmware:read and firmware:update permissions. Translations added in all 4 locales.ja.ts from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.h2d), causing firmware checks to offer H2D firmware instead of H2C firmware. H2C has its own firmware track (01.01.x.x vs H2D's 01.02.x.x). Added separate h2c API key mapping. Also added missing H2C/H2S entries to printer model ID and 3MF model maps.invert() filter. The filter was intended for monochrome preset icons but was incorrectly applied to user-uploaded images (e.g., full-color logos). Removed the invert filter from custom icon rendering in the sidebar and the add/edit link modal.[Errno 104] Connection reset by peer while the small verify_job always succeeded. The _handle_data_connection callback returned immediately, allowing the asyncio server-handler task to complete while the data connection was still in active use. The passive port listener also stayed open during transfers, risking duplicate data connections. Fixed by keeping the callback alive until the transfer completes (_transfer_done event), closing the passive listener after accepting the connection, and rejecting duplicate data connections. Also added a 5-second drain timeout to MQTT status pushes to prevent blocking when the slicer is busy uploading.remote_interface_ip setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from _get_local_ip() followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).subtask_name and never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start./usr/etc/print/auto_cali_for_user.gcode) and other internal printer files under /usr/ are now detected and skipped during print start.sendBeacon) failed with 401 Unauthorized when authentication was enabled because sendBeacon cannot send auth headers. Replaced with fetch + keepalive: true which supports Authorization headers while remaining reliable during page unload.filament_used_grams by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in FilamentTrends.tsx.homeassistant_service was not configured with HA URL/token before querying plug energy data, causing it to silently return nothing.use_ams: 1 (integer) as a nozzle index, routing filament to the deputy nozzle instead of the main nozzle. Bambu Studio sends use_ams: true (boolean) while using integers for other fields. Fixed by keeping use_ams as boolean for all printers including H2D series.ams_unit_count: 0 because it expected raw_data["ams"] to be a nested dict ({"ams": [...]}) but the MQTT handler stores it as a flat list. Now handles both formats.latest_version, since there is nothing to compare against.ams_id * 4 + tray_id (= 512), but AMS-HT uses the raw ams_id (128) since it has a single tray. The backend then misidentified 512 as an external spool. Fixed in frontend tray ID calculation, backend ams_mapping2 builder, print scheduler, and Spoolman tracking.getPrinterImage() to return it for H2C models.id - 16). Filament colors and materials were missing because the H2C uses different MQTT field names (color_m, fila_id, sn, tm) than the H2D (filament_colour, filament_id, serial_number, max_temp). Added fallback field name resolution. Also fixed nozzle rack layout breaking on medium card size by allowing the temperature row to wrap.mock_ftp_server.py) implements implicit TLS, custom AVBL command, and per-command failure injectiontransfercmd(), progress callbacks, 553/550/552 error handlingerror_perm hierarchy, diagnose_storage CWD propagation, injection count decrementpyOpenSSL to requirements-dev.txt for Docker test image compatibilitydiagnose_storage() was running before every upload, and its CWD failures (ftplib.error_perm) were not caught because error_perm is not a subclass of error_replydiagnose_storage() from the upload hot pathexcept (OSError, ftplib.error_reply) to except (OSError, ftplib.Error) to catch all FTP error types/api/v1/archives/{id}/reprint and /api/v1/library/files/{id}/print caused by the FTP failure above/api/v1/printers/{id}/cover when FTP download returned 0 bytes but reported success; now retries and falls back to 4040.1.8.1 for hotfixes without incrementing the minor versionxml.etree.ElementTree with defusedxml across all 3MF parsing code../ sequences.codeql/python-bambuddy.qls, .codeql/javascript-bambuddy.qls) with documented accepted-risk exclusions%s style across all backend filesexcept Exception blocks to specific types (OSError, KeyError, ValueError, zipfile.BadZipFile, sqlalchemy.exc.OperationalError, etc.)str(e) with generic error messages in HTTP responses (updates.py)homeassistant.py)tasmota.py)usedforsecurity=False to non-security hash calls (MD5 for AMS fingerprinting, SHA1 for git blob format)test_security.sh uses --threads=0 for all CodeQL commands (auto-detects CPU cores).trivyignore to suppress accepted Dockerfile USER directive findingAmbientCapabilities=CAP_NET_BIND_SERVICE capabilityfilament_used_grams field already contains the total for the entire print job* quantity multiplication from archive stats, Prometheus metrics, and FilamentTrends charttray_info_idx (filament type identifier)tray_info_idx (e.g., "GFA00" for generic PLA) identifies filament TYPE, not unique spoolsfind() which always returned the first match regardless of colorstorbinary() with manual chunked transfer using transfercmd()storbinary() waiting for completion responsePUID=$(id -u) PGID=$(id -g) docker compose up -dstart_bambuddy.bat for Windows users - double-click to run, no installation required.portable\ folder for easy cleanupstart_bambuddy.bat (launch), start_bambuddy.bat update (update deps), start_bambuddy.bat reset (clean start)set PORT=9000 & start_bambuddy.batRequirePermissionIfAuthEnabled() for permission checks<img> tags which cannot send Authorization headers/api/v1/spoolman/spools/linked endpoint returning map of linked spool tags to IDsscript.* entities; now shows all HA entities with toggle enabledfilament_used_grams by quantity/archives/stats) and Prometheus metrics also fixedfetch() without Authorization headerformatDateInput, parseDateInput, getDatePlaceholderformatTimeInput, parseTimeInput, getTimePlaceholderproject relationship not eagerly loaded in get_archive() service methodSecurity Release: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately.
JWT_SECRET_KEY environment variable (recommended for production).jwt_secret file in data directory with secure permissions (0600)/api/ routes when auth is enabled*_own and *_all variants:queue:update_own / queue:update_allqueue:delete_own / queue:delete_allarchives:update_own / archives:update_allarchives:delete_own / archives:delete_allarchives:reprint_own / archives:reprint_alllibrary:update_own / library:update_alllibrary:delete_own / library:delete_all*_all permissions (can modify any items)*_own permissions (can only modify their own items)*_all permissioncreated_by_id columns to print_archives, library_files, and print_queue tablesprinters:ams_rfid permission for re-reading AMS RFID tagsqueue:create permission for users with restricted access?fps=30 parameter to control camera frame rate (1-30, default 15)?camera=false parameter to hide camera and show only status overlay on black backgroundlibrary:read permission for File Manager endpoints:
library:read permission check to all list/view endpoints (files, folders, stats)library:upload permission check to upload and folder creation endpointsqueue:create permission check to add-to-queue endpointprinters:control permission check to direct print endpointlibrary:read permission can no longer view files in the File Manager*_all permissions<img> don't send Authorization headersams_mapping2 slot_id handling that caused AMS mapping failuresskip_session_reuse to ImplicitFTP_TLSsliced_for_model column that was missing in some upgrade paths/overlay/:printerId combining camera feed with status overlay?size=small|medium|large and ?show=progress,layers,eta,filename,status,printerpower_l1, data.power)GET /api/v1/printers/usb-cameras)GET /api/v1/metrics (Prometheus text format)/api/settings for Home Assistant rest_command compatibility (Issue #152)/ to search paths when looking for 3MF files/cachetray_exist_bits bitmask to detect and clear empty slotsGET /api/v1/smart-plugs/ha/sensors to list available energy sensors{finish_photo_url} template variable for print_complete, print_failed, print_stopped eventscompleted_at - started_at) instead of slicer estimates; cancelled prints only count time actually printed (Issue #137){"text": "..."} instead of custom fields (Issue #133)library_file_id directlyselectedFolderId from useEffect dependency array that was causing a reset looptray_info_idx from the preset's base_id when filament_id is null