Note
This is a daily beta build (2026-05-30). It contains the latest fixes and improvements but may have undiscovered issues.
Docker users: Update by pulling the new image:
docker pull ghcr.io/maziggy/bambuddy:daily
or
docker pull maziggy/bambuddy:daily
**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
Added
- System theme detection — sidebar toggle and Settings selector follow OS dark/light preference (#1418, contributed by TempleClause via PR #1501) —
ThemeModegains a third value'system'alongside the existing'dark'/'light'. The provider listens towindow.matchMedia('(prefers-color-scheme: dark)'), tracks the OS preference in real time, and exposes a newresolvedMode: 'light' | 'dark'to consumers — the actual rendered theme after resolving system → OS preference. Layout's sidebar toggle now cyclesdark → light → system → darkwith the icon hinting at the next stop (Sun→Monitor→Moon); the existing logo selection and the dark/light "active" panel highlight in Settings switched frommodetoresolvedModeso they always reflect what's actually painted, regardless of whether the user chose explicitly or inherited from the OS. Settings → Appearance gained a 3-button Dark / Light / System selector (border-green-keys-off-modeso System actually highlights System even when it resolves to dark), with a "Settings saved" toast on click matching the adjacent Background/Accent/Style selects. Existing users' persistedtheme-modeis untouched — anyone ondarkorlightstays there and simply gains an extra stop in the cycle; new installs default todark. Review-caught fixes shipped in the same PR: (a) the project's__tests__/setup.tsmockedwindow.matchMediawithvi.fn().mockImplementation(...), whichvi.restoreAllMocks()in three test files reset to "return undefined" — pre-PR nothing calledmatchMediaat render time so the wipe went unnoticed, this PR was the first caller and broke 23 existing tests. Rewritten as a plain function (Object.defineProperty(window, 'matchMedia', { writable: true, value: (query) => ({...}) })) sorestoreAllMockscan't touch it. (b)themeToggleHinthad previously only been updated inen.ts; real translations now ship in all 8 non-English locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) describing the 3-state cycle without referencing the old sun/moon icon pair. (c) PR description reworded to honestly call out the sidebar cycle change as a behaviour change for every user of the toggle (dark → light → systemnow intercepts where users previously gotdark → light → dark), with the persisted-preference-unchanged caveat made explicit. (d) New i18n keynav.switchToSystemwith real translations across all 9 locales ('Switch to system mode'/'Zum Systemmodus wechseln'/'システムモードに切替'etc.). Tests: 11 new inThemeContext.test.tsx(systemPreference inits frommatchMedia.matches, change event updates state, resolvedMode follows explicit mode vs systemPreference permodevalue, dark class applied based on resolved mode,toggleModecycles dark→light→system→dark); 1 new inLayout.test.tsx(toggle button title attribute walks the cycle); 4 new inSettingsPage.test.tsx(all three buttons render, active green border keys offmode, click switches mode, click fires toast). 26 previously-broken tests inAddNotificationModal.test.tsx+NotificationProviderCardStockAlerts.test.tsx+CameraTokensPage.test.tsxpass again post-setup.tsfix. Frontend build clean (2682 modules); i18n parity green at 4995 keys × 9 locales (+1 fromswitchToSystem). Contributor handled the entire round-1 review (matchMedia mock, locale parity, PR honesty, full test coverage, toast parity,.map()refactor for the button group) in a single revision push, no follow-ups deferred. - MQTT auth rate-limit on the virtual printer — Bambuddy's VP exposes an 8-char access code via the slicer-facing MQTT server on port 8883. Without a rate limit the code is brute-forceable by anyone who can reach the VP's bind IP (LAN, Tailscale, or any other tunnel the user chose to expose). The new per-IP limiter records each failed CONNECT auth attempt and rejects further CONNECTs from that IP once 5 failures occur within a 60 s window. The window is sliding (not cumulative), recovers automatically after expiry — no manual unblock — and successful auth clears the IP's prior failure history so a user who fat-fingered their code 3 times then got it right isn't penalised on their next reconnect. Per-IP tracker uses
time.monotonic()so wall-clock jumps can't extend or shorten the window unexpectedly. Constants_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5and_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0are module-level for ops tunability. 5 unit tests intest_vp_mqtt_server.py::TestAuthRateLimitpin the under-limit/at-limit/window-recovery/multi-IP/success-clears semantics. - Per-slicer MQTT response routing for multi-slicer VP setups — Pre-fix: when slicer A sent
extrusion_cali_get(or any other bridge-forwarded command) to a non-proxy VP bound to a target printer, the printer's response was fanned out to every connected slicer — leaking slicer A's response into slicer B's command stream. Slicers ignore responses to sequence_ids they didn't send, but the leak is still wrong and could confuse multi-slicer-host setups (workstation + laptop both connected to the same VP). The fix recordssequence_id → originating client_idinSimpleMQTTServer._pending_requestson the way out and looks it back up inpush_raw_to_clientson the way in, routing the response only to that one client. Falls back to broadcast for printer-initiated unsolicited pushes (push_status etc. — every slicer expects these) and for sequence_ids the map never saw recorded (covers slicers subscribing mid-flight). Bounded at 256 entries with FIFO eviction so a slicer that sends commands without ever consuming responses can't leak memory. 6 unit tests intest_vp_mqtt_server.py::TestPendingRequestRoutingcover seq-id capture across nested blocks, lookup-pops-entry semantics, FIFO eviction at cap, malformed-payload fallback, and broadcast on unrecorded seq. - H2D Pro virtual-printer support (experimental — needs field confirmation) — Added SSDP model codes
O1EandO2DtoVIRTUAL_PRINTER_MODELSand matching09400Aserial prefixes toMODEL_SERIAL_PREFIXESso the H2D Pro shows up in the Add Virtual Printer model dropdown and advertises a model code distinct from H2D'sO1D. The codes were transcribed from the project's model-codes reference but have not been validated against a live H2D Pro's SSDP response. Anyone with an H2D Pro who picks this from the dropdown should confirm BambuStudio recognises the VP correctly; if not, the code values need a one-line correction and a follow-up release. - VP child-service readiness barrier — Pre-fix:
VirtualPrinterInstance.start_serverspawned each child sub-service (FTP, MQTT, Bind, SSDP) as aasyncio.create_taskand returned immediately.is_runningthen reportedTrueeven though the child sub-services' sockets were still in the gap betweenasyncio.create_task(...)and the innerasyncio.start_serverreturning. A caller racing the start (the diagnostic route, the VP-card UI poll, an integration test) could seerunning=passwhileport_ftps=fail. Each child now exposes aready: asyncio.Eventthat's set after the actual socket bind, andstart_serverawaits all of them with a bounded 5 s timeout. If a child hangs binding, the timeout logs aSub-service didn't bind within 5s: ...warning and the VP continues — the existing task-tracking still catches the failure on the next iteration. The 5 s ceiling is well above any legitimate bind on healthy hardware; on a Pi 3 with a congested SD card it's tight but bounded.
Changed
- Bug-report template: tightened fields + new Area dropdown to cut invalid-issue triage load — 170 issues have been closed with the
invalidlabel (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The template now forces engagement with the tools that were already shipped. Form changes (bug_report.yml): (a) the "I ran the Connection Diagnostic" checkbox flipped fromrequired: falsetorequired: true, so the form blocks submission until the reporter has actually used the diagnostic (or knowingly lied — higher friction than reading the doc); (b) the Support Package textarea is nowrequired: trueinstead of optional, with the field's prompt rewritten to "Drag the .zip here, or explain why you cannot attach one" so users without a working Bambuddy still have a path; (c) a new required "Troubleshooting steps already taken" textarea sits between Steps to Reproduce and the printer-model dropdown, asking which wiki pages were checked and which in-app diagnostics were run — empty answers can't submit, which produces either real evidence or an admission that nothing was tried (both of which are useful for triage); (d) the pre-form markdown intro now spells out the "search → wiki → diagnostic → support package" sequence with a citation of the 1-in-5 stat so reporters understand the why before they reach the fields; (e) the final-checks list grew from one to three required confirmations (searched issues + checked troubleshooting wiki + ran Connection Diagnostic for connection/printing/camera bugs), with the wiki-checked confirmation linking to the rendered troubleshooting page. Bug categorization (the gap that motivated the rewrite): the old singleComponentdropdown only carriedBambuddy / SpoolBuddy / Both— useless for area triage. Replaced with TWO required dropdowns:Product(Bambuddy / SpoolBuddy) andArea(15 options covering the actual feature surface — connection, dispatch, filament/AMS, slicer, VP, camera, archives, stats, queue, notifications, auth, updates, UI, integrations, SpoolBuddy kiosk, plus an Other escape hatch). Auto-labeling (.github/workflows/auto-label-area.yml): on every issue open/edit, anactions/github-scriptv7step parses the Area dropdown out of the rendered issue body (matching the### Area\n\nValueblock GitHub forms produce) and applies the matchingarea:*label. Tolerant of CRLF, the_No response_placeholder, and the issue-edit re-fire path (won't re-add an already-present label). Unrecognised Area values emit acore.warningso missed sync between the form and the workflow map shows up in Actions logs. Maintainer hand-off: 15area:*labels need to be created once viagh label create(see commit message for the exact commands) — labels referenced by the workflow but missing in the repo cause theaddLabelscall to throw, so this prerequisite is load-bearing. Printer Model dropdown verified againstPRINTER_MODEL_MAPinbackend/app/utils/printer_models.py— all 13 current Bambu models present (X1 Carbon / X1 / X1E / X2D / P1S / P1P / P2S / A1 / A1 Mini / H2D / H2D Pro / H2C / H2S), no update needed. YAML syntax validated via Pythonyaml.safe_loadfor both the template and the workflow. - VP virtual-printer FTP server: cmd_STOR streams chunks straight to disk instead of buffering the whole upload in memory — Pre-fix:
cmd_STORaccumulated every chunk in alist[bytes]and calledwrite_bytesat the end. Peak RSS for a multi-GB.gcode.3mf(multi-plate dense prints) was ~2× the file size — chunks held + theb''.joinof them — and could OOM-kill a low-memory host (Pi 3, low-end Synology, etc.). The streaming rewrite writes each 64 KiB chunk tofile_path.open("wb")inline as it arrives, bounding peak memory at one chunk regardless of total upload size. Wire protocol unchanged — same150 → 226sequence, same destination path, no new verbs, no concurrency guard. The visible difference is that the destination file grows progressively rather than appearing all-at-once on completion; slicers don'tLISTduringSTORso this isn't observable. Same change adds aMAX_UPLOAD_BYTES = 4 GiBhard cap — a runaway or malicious client can no longer drive RSS or disk to exhaustion. On the cap path the partial file is unlinked so a slicer retry starts clean. 4 unit tests intest_vp_ftp_stor.py(happy-path bytes on disk + 226, cap-violation 426 + partial cleanup, mid-stream read error cleanup, MAX_UPLOAD_BYTES sanity floor). - VP virtual-printer FTP passive port range widened from 50000-50100 (101 ports) to 50000-51000 (1001 ports) — The original range was sized for a single VP. With multiple VPs each running their own FTP server, concurrent passive data connections compete for the 101-port pool and the bind-retry loop's 10 random picks can collide; 1001 ports gives headroom. Only affects the non-proxy path (
VirtualPrinterFTPServer.PASSIVE_PORT_MIN/MAX). The proxy path'sSlicerProxyManager.FTP_DATA_PORT_MIN/MAXstays at 50000-50100 because it pre-binds the printer-side range exactly. Docker bridge-mode users mapping the old range need to update to50000-51000:50000-51000—docker-compose.yml,install/docker-install.ps1warning, and the wiki (docs/getting-started/docker.md,docs/features/virtual-printer.md— port table, two UFW rules, two firewalld rules, Cloudflare-tunnel list, firewall troubleshooting line) all updated with "widened in 0.2.5" notes. Docker host-mode and bare-metal users are unaffected (no port mapping involved). The proxy-mode FTP-data row in the wiki stays at 50000-50100 because that path is unchanged. - VP MQTT bridge sticky-keys: 7 more fields preserved across incremental pushes — Pre-fix: when the bridge cached a real printer's
push_status, the very next 1 Hz incremental push (which only carries changed temps / fan / wifi_signal) wiped any field not in the sticky-keys allowlist. The cached state lostupgrade_state,xcam,hw_switch_state,nozzle_diameter,nozzle_type,onlineandams_statusafter a single tick — BambuStudio's Send pre-flight reads several of these (upgrade_state.dis_state/force_upgradein particular) and could refuse Send because the cached push said "unknown firmware state". Same shape as #1228 (storage indicators) and #1558 (live-progress fields) — the cached-branch field-shape parity, not a new mechanism. Sticky-keys carry-forward is now also acopy.deepcopy(was reference) so a future merge that mutates a carried-forward dict in place can't corrupt both copies. - VP target-printer DHCP IP / serial refresh now restarts proxy VPs — Pre-fix: when a target printer's IP changed (DHCP renewal, network reconfiguration), the running proxy VP kept forwarding to the stale IP forever because
sync_from_db's "changed" predicate didn't compareproxy_ipsagainst the running instance'starget_printer_ip/target_printer_serial. The user had to manually toggle the VP to refresh. Nowsync_from_dbre-evaluates the proxy target each cycle and restarts the VP when the IP or serial actually changes — same code path as a config change. If the target printer's DHCP lease cycles frequently this means more proxy restarts, but the alternative was silent breakage; documented in the release-notes for users on flaky-DHCP networks. - VP queue_force_color_match setting takes effect immediately — Pre-fix: toggling the per-VP
Force exact color matchsetting via the UI silently no-op'd becausesync_from_db's "changed" predicate didn't include the field. The user had to restart the process for the new value to land. The predicate now also checksqueue_force_color_matchso the running instance gets restarted on toggle. - VP MQTT client session errors elevated from DEBUG to WARNING — The outer
except ExceptioninSimpleMQTTServer._handle_clientwas logging at DEBUG, which production deployments default to suppressing. Users reporting "slicer disconnects randomly" then had no signal to pass us. WARNING surfaces it. Inner handlers' expected parser/IO failures stay at DEBUG — only unexpected errors that would otherwise reach the outer catch get visibility. - VP MQTT periodic status push now logs a one-line per-minute counter per active slicer connection (#1548 follow-up) —
_periodic_status_pushemits1Hz status push: N pushes/min to <client>at INFO level once per minute per connected slicer (silent when no slicer is attached). The 1 Hz status push was previously silent at INFO; when a reporter sent a support bundle showing an idle disconnect, there was no way to tell whether the push task was actually pushing to that connection or being eaten silently. The counter both confirms the task is healthy for a given client and gives us a concrete data point (N < 60 means pushes were dropped) when triaging future "slicer disconnects on idle" reports. No behaviour change to the push itself.
Security
- Trivy DS-0026 (
Dockerfile.testmissing HEALTHCHECK): silenced viaHEALTHCHECK NONE— The test image runspytestand exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise.HEALTHCHECK NONEis the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813. - VP access codes now compared with
hmac.compare_digest(constant-time) — Pre-fix: bothFTPSession.cmd_PASSandSimpleMQTTServer._handle_connectused Python's==operator on the 8-char access code. Constant-time comparison closes the timing-side-channel without changing the protocol surface. Same auth, no UX change. - VP MQTT brute-force rate-limit per source IP — 5 failed CONNECT attempts within a 60 s sliding window block further auth attempts from that IP for the rest of the window. Auto-recovers — no manual unblock. Constants
_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5/_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0are module-level for ops tunability. See Added section for full description. - VP
access_codeno longer leaked in DEBUG logs — Pre-fix:PUT /virtual-printers/{id}loggedbody.model_dump(exclude_unset=True)at DEBUG, which dumped the plaintext access code whenever the user saved a new one. Now the field is redacted (***) before the log emission. Violation surfaced by no-secrets-in-logs audit; not exploitable in the field (DEBUG is off by default) but is exactly the kind of leak the rule exists to prevent. - VP FTP upload capped at 4 GiB (DoS guard) —
cmd_STORnow rejects an upload that crossesMAX_UPLOAD_BYTES = 4 GiB, deletes the partial file, and replies 426. Without the cap a runaway or malicious client could drive RSS or disk to exhaustion; 4 GiB is well above any realistic multi-plate.gcode.3mf. Same code path adds the streaming rewrite (see Changed section for details).
Fixed
- Sliced
.gcode.3mffiles now render in the 3D preview and expose a Preview-3D action in the file row (#1543, reported by Vlado-Tarakan) — Reporter exported a multi-plate.gcode.3mffrom Bambu Studio to the shared folder Bambuddy watches and the 3D preview tab came up empty; if he re-uploaded the same file via the file manager, the preview worked. Root cause: two paths classifyfile_typedifferently.backend/app/api/routes/library.py:1343-1348(the shared-folder scan path) does a compound-extension check and tags the filegcode.3mf; the upload path at the same file's1588does a singleext[1:]and tags it3mf. Thenfrontend/src/components/ModelViewerModal.tsx:71-73hadhasModel = normalizedType === '3mf' || 'stl'andhasGcode = normalizedType === 'gcode' || '3mf'— neither matchedgcode.3mf, so the capabilities object landed with both flags false and the modal rendered an empty bed.FileManagerPage.tsx:858also gated the Preview-3D context action onfile_type === '3mf' || 'gcode' || 'stl', so for shared-folder files the entry didn't even appear, and the type pill at765-770had no colour case forgcode.3mfso it fell through to the generic gray. Fix (frontend-only, no backend churn):ModelViewerModal.tsxintroduces anisThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'predicate used in two places — the capabilities branch (hasModel = isThreeMfFamily || 'stl',hasGcode = isThreeMfFamily || 'gcode') and the plates-loading branch that previously hard-gated on!== '3mf'and would have returnedsetPlatesData(null)for the shared-folder file.FileManagerPage.tsxaddsgcode.3mfto the Preview-3D action gate and shares the gcode blue type-pill colour so sliced-output files are visually distinguishable from source 3MFs. The compoundgcode.3mfclassification on the backend is intentionally preserved — it carries useful "this is a sliced output" semantics that other UI surfaces could use later. ThecanOpenInSlicerandsliceableTypechecks atModelViewerModal.tsx:269, 277-280are deliberately left alone — a sliced output isn't openable in the slicer, andsliceableTypealready explicitly excludes.gcodeand.gcode.3mfper the comment "the file type can't be sliced". Out of scope (separate Bambu-Studio format limitation, not a Bambuddy bug): Vlado's secondary observation that the upload-path 3D preview "shows only one plate" even though his project has 5 plates — Bambu Studio's.gcode.3mfexport contains the g-code and model data for the active plate only, not the entire multi-plate project. The print picker enumerates plates viagcode_*.gcodeentries inside the zip (a separate code path), which is why the user can still pick the plate at print time. The empty-bed fix is the data point that closes the user-visible bug. Tests: existing full 2043-test frontend suite green; no test asserted on the unsupportedgcode.3mfcapabilities branch (the change is additive —3mfandstlandgcodebehaviours are unchanged). Frontend build clean. - Connected-edge reconciliation closes the missed-PRINT-COMPLETE loop that produced ghost replays on smart-plug power cycles (#1542 follow-up, reported by vixussrl-ui) — Reporter ran a fresh trace after the doubled-extension fix landed and found a distinct second cause behind his ghost prints, hitting 4-of-4 of his A1s. Timeline: 22:50 PRINT START → print runs all night → MQTT disconnects multiple times (A1's keepalives are unstable on his network) → print finishes during one of those disconnect windows so PRINT COMPLETE is never observed → smart plug cuts power on idle → power resumes for the next scheduled print → firmware auto-replays the leftover
.3mffrom the SD card → Bambuddy reconnects to a fresh PRINT START for the ghost. The existing IDLE-after-RUNNING completion check atbackend/app/services/bambu_mqtt.py:3022was meant to catch the simple disconnect-then-finish case via_previous_gcode_statepreserved across reconnects, but with multiple disconnect/reconnect cycles + a smart-plug power-off that Bambuddy can't distinguish from any other transient drop, the IDLE window that branch needs simply never reaches it. The SD.3mflingers, the firmware ghost-replays every power cycle, and the loop repeats until the operator notices. Fix: a new connected-edge reconciliation pass — newreconcile_stale_active_prints(printer_id)inbackend/app/main.pyqueries archives instatus="printing"for the printer at MQTT (re)connect time and synthesiseson_print_complete(status="aborted")for any whose print can't actually be running anymore. The decision is made by a pure_is_active_archive_stale(archive, state)function with three triggers: (1) current printer state is terminal (IDLE / FINISH / FAILED) — covers the clean disconnect-then-finish case the existing #3022 branch was already trying to handle; (2) printer is running but with a differentsubtask_idthan the archive — Bambu firmware mints a freshsubtask_idfor each print including the ghost-replay it runs after a power cycle, so a mismatch is unambiguous evidence the in-DB archive is no longer the print on the printer; (3) printer is running butsubtask_nameis empty — the printer doesn't know what it's running, archive reference is broken. PAUSE / PREPARE / SLICING / RUNNING with matching subtask are intentionally left alone — false positives there cost a single misreported "aborted" status that the real PRINT COMPLETE would have overwritten anyway, while a false negative is the ghost-print loop being reported. The synthesisedon_print_completereuses the existing chain (SD cleanup, status update, usage tracker, notifications) — no reimplementation, no duplicate event when real completion later fires (the second call seesstatus != "printing"and falls through). Status"aborted"is the conservative label; we have no progress evidence to promote to"completed". Wiring: new_printer_reconciled_since_connect: dict[int, bool]edge tracker at module scope, checked at the start ofon_printer_status_change— whenstate.connectedflips False → True (which covers both Bambuddy startup with no prior connection AND a mid-session MQTT reconnect), reconciliation fires exactly once for that connection. Setting the edge to True BEFORE the spawned task starts prevents concurrent status updates within the same connection from re-triggering it. Concurrency: reconciliation runs asasyncio.create_taskso it doesn't block the WebSocket dedup / broadcast logic that on_printer_status_change is the hot path for. Ghost-print collateral worth being explicit about: if the ghost is already running when reconciliation fires, the synthesised SD-cleanup will hit 550-file-locked (firmware locks the file during print, same cause as the #1542 first case). The cleanup retries 3× then logs "lingering" — same as any other in-print cleanup attempt. The ghost runs to completion, its own end-of-print cleanup deletes the file, and the next power cycle has nothing to replay. The loop breaks even when reconciliation can't physically delete the file mid-ghost. A perfect cancel would require sending aprint_stopMQTT command to the printer, which is invasive and explicitly out of scope. Tests: 21 intest_reconcile_stale_active_prints.py—TestIsActiveArchiveStalecovers all three stale triggers with case-insensitive state matching, the four healthy-no-op cases (RUNNING / PAUSE / PREPARE / SLICING with matching subtask), the IDLE-overrides-subtask-match precedence, and the missing-subtask_id edge cases that fall through to the subtask_name check.TestReconcileStaleActivePrintscovers the orchestrator: no-status, disconnected-status, and no-active-archives all short-circuit; a stale archive produces a synthesisedon_print_complete(status="aborted", _reconciled=True)payload with the archive filename; a healthy in-flight archive doesn't fire any completion; an exception inside one archive's synthesis doesn't block the rest or propagate to the caller. Full 5399-test backend suite green (5378 + 21 new). Backend ruff clean. - Fallback-archive MQTT filament extraction now actually fires for real prints (#1533 follow-up, reported by JmanB52D) — Reporter updated to 0.2.5b1 expecting the #1533 fix to populate filament fields on his P2S virtual-printer prints when the .3mf is locked. His support bundle showed Bambuddy still creating fallback archives with NULL filament fields even though the print-start log line proved AMS-0-T0 had PETG loaded at the moment the helper should have read it (
AMS 0: T0(type=PETG, color=FFFFFFFF, …)). Cause: the #1533 helper_extract_filament_data_from_mqtt(data)inbackend/app/main.pyonly looked atdata["ams"], but the dict thaton_print_startactually receives at runtime is the wrapper shape{"filename", "subtask_name", "remaining_time", "raw_data": <mqtt_payload>, "ams_mapping"}thatbackend/app/services/bambu_mqtt.py:2971-2980constructs — sodata["ams"]was undefined on every real call and the helper silently returned{}, leaving the fallback archive'sfilament_type/filament_colorNULL. The 15 unit tests that shipped with #1533 all passed the bare inner shape directly and never exercised the callback wiring, so the regression slipped through the green build. Fix: the helper now resolvesdata["raw_data"]["ams"]first (the callback shape) and only falls back todata["ams"]when the wrapper isn't present (preserves the inner-shape callers from the existing tests). Defensive: a non-dictraw_data(e.g. partial MQTT decode failure) falls through to the inner lookup instead of crashing. Tests: 5 new inTestOnPrintStartCallbackShape(backend/tests/unit/test_fallback_archive_mqtt_filament.py) — wrapper payload with ams_mapping resolves to the inner data; wrapper with no ams_mapping lists all loaded slots; the existing inner-shape callers still work after the additive wrapper lookup; missingraw_datareturns{}instead of raising; junkraw_data(string) doesn't shadow a present innerams. Full 5378-test backend suite green. Backend ruff clean. What this does NOT fix: per-filament gram usage still needs the actual .3mf — the printer locks it during print (P-line firmware behaviour, not a Bambuddy bug), and the existing 19 FTP candidate paths + directory probes are expected to 550 in that window. Per-print filament type and colour are the data point that drives the AMS-expansion planning the reporter explicitly called out, so this is the fix that moves the needle for him. - Assigning a spool no longer shows a profile-mismatch warning when only the slicer profile differs, and the warning now states the AMS slot will be reconfigured (#1552, reported by anthonyma94) — Reporter assigned a spool to a slot whose stored slicer profile (e.g. "Bambu PLA Matte") differed from the new spool's profile (e.g. "Bambu PLA Basic"), got a warning popup with only Cancel / Assign Anyway, and was under the impression that confirming the popup just linked the spool in Bambuddy's DB without touching the AMS — i.e. that he then had to manually open Configure AMS Slot to push the new profile to the printer. The auto-push has actually been in place since the assign route existed:
backend/app/api/routes/inventory.py::assign_spoolcallsapply_spool_to_slot_via_mqttafter upserting the SpoolAssignment row, which publishes bothams_filament_setting(tray_info_idx, tray_sub_brands, color, temps) andextrusion_cali_sel(K profile) over MQTT, andbackend/app/api/routes/spoolman_inventory.py::assign_spoolman_slotdoes the same on the Spoolman side. The only short-circuit is when the firmware explicitly reports the slot empty (tray_state ∈ {9, 10}), in which casemain.py::on_ams_changedeferred-replays the configure as soon as a spool appears. So the popup was creating friction without revealing what it actually did. Two changes: (1)AssignSpoolModal.tsx+spoolbuddy/AssignToAmsModal.tsxno longer fire the mismatch popup for profile-only mismatches —if (materialMatchResult !== 'exact')replaces the oldmaterialMatchResult !== 'exact' || !profileMatches, and the'profile'member is dropped from themismatchTypeunion (the standalone profile branch in both popup render bodies is removed as dead code). Material mismatch — where Bambu firmware can refuse the print because the type is wrong — still warns. (2) Every firing warning (material, partial, material+profile, partial+profile) now appends a new line via the newinventory.assignReconfigureNotei18n key: "The AMS slot will be reconfigured to use the spool's profile." This makes the Assign Anyway button's effect explicit instead of leaving users to guess. i18n: real translations across all 9 locales per [[feedback_translate_dont_fallback]]; parity script clean at 4999 leaves per locale. Tests: existing 14AssignSpoolModal+ 7AssignToAmsModaltests pass unchanged — no test asserted on the profile-only popup firing. Frontend build clean, full 2043-test suite green. Open follow-up: if anthonyma94 confirms after this change that his slot still shows the old profile after Assign Anyway, the real bug is inapply_spool_to_slot_via_mqtt's tray_info_idx / setting_id resolution for his specific spool shape — would need his spool'sslicer_filamentvalue plus the live tray state to diagnose. - Transparent / clear filament now selectable and rendered as transparent end-to-end in the built-in inventory (#1545, reported by Synec5, confirmed by CMW-ISS) — Reporter wanted to select a transparent filament colour in the spool editor; CMW-ISS independently confirmed on v0.2.5b1 that AMS-detected transparent spools were silently labelled "Black" in the filament-mapping dropdown because the colour name resolver dropped the alpha byte and the underlying RGB
000000HSL-bucketed to "Black". Spoolman already supported 8-digitRRGGBBAAhex; the built-in inventory didn't. Five distinct sites collapsed alpha → 6-char RGB and had to be fixed together: (a)frontend/src/utils/colors.ts—hexToColorName,getColorName,resolveSpoolColorName, andisLightColornow short-circuit to"Clear"when the input is 8 chars with alpha00, before either the catalog lookup or the HSL fallback can mislabel transparent as black;isLightColorreturnstruefor clear so text contrast matches the light/mid-gray checkerboard underlay the swatch paints. (b)frontend/src/utils/amsHelpers.ts::normalizeColorno longer unconditionally strips the alpha byte — it preserves#RRGGBBAAwhen alpha <FFso the AMS-side colour reaches CSSfill=/backgroundColoras a translucent value instead of a solid one; opaque colours still emit#RRGGBBandnormalizeColorForCompare(which DOES strip alpha) is unchanged so type/colour matching for auto-mapping is unaffected. (c)backend/app/api/routes/printers.py::get_available_filamentsno longer truncatestray_colorto 6 chars before emitting it on/printers/available-filaments— both the AMS andvt_traybranches now pass the full#RRGGBBAAthrough; the dedup key still uses the 6-char RGB so two slots that share an RGB but differ only in alpha still merge into one filament requirement. (d)frontend/src/components/spool-form/constants.tsgained a{ name: 'Clear', hex: '00000000' }entry toQUICK_COLORS— the only 8-char preset, because the native<input type="color">can't pick alpha and a dedicated swatch is the only UX that lets the user actually choose transparency. (e)frontend/src/components/spool-form/ColorSection.tsxreworked the hex draft contract: previously the hex input was hardcoded to 6 chars and every commit path unconditionally appended'FF', so even pasting00000000got truncated to000000FF(solid black). Now: the draft accepts up to 8 hex chars; a 6-char commit appendsFF, an 8-char commit passes through verbatim; on blur a 7-char draft (RGB + one alpha nibble) right-pads the nibble to0instead of jumping back to 6-char-pad-RGB; theselectColor()helper that the preset swatches call only appendsFFwhen the preset is 6 chars, so the newClearswatch lands as00000000informData.rgbainstead of00000000FF.currentRgbais canonicalised to 8 chars uppercase andisSelected()matches on the full rgba soClear(00000000) doesn't collide withBlack(000000FF) in the swatch highlight. (f) Two new shared helpers infrontend/src/utils/colors.ts—getSwatchStyle(rgba)returns a{ backgroundColor }for opaque colours and a{ backgroundImage, backgroundSize }8px checkerboard for alpha=00 (use for div / button backgrounds);spoolColorString(rgba)returns a hex string that preserves the alpha byte when alpha < FF (use for SVGfill=props and other single-string colour contexts where the consumer can interpret 8-char hex natively). Applied to every simple-swatch site that previously didstyle={{ backgroundColor: '#' + rgba.slice(0, 6) }}or passed a 6-char fill to an SVG icon — those sites would have rendered Clear spools as solid black after the cream rewrite was removed: the three preset rows inColorSection.tsx(recent / catalog / fallback), the spool checkbox swatch inLabelTemplatePickerModal.tsx, the per-card colour dot + the SVGSpoolCircleinSpoolBuddyInventoryPage.tsx, the assigned-spool indicators inSpoolBuddyAmsPage.tsx(both internal and Spoolman branches), the four selected-spool summary swatches + the simple-view's spool dot inSpoolBuddyWriteTagPage.tsx, the lead-spool indicator inForecastPanel.tsx, the header swatch inAssignToAmsModal.tsx, the two spool-list dots inAssignSpoolModal.tsx(internal + Spoolman columns), and theSpoolIconfed byInventorySpoolInfoCard.tsx/TagDetectedModal.tsx/SpoolInfoCard.tsx/LinkSpoolModal.tsx(which now pass the full 8-char rgba — SVGfill=interprets translucent values correctly).FilamentSwatch.tsx's tooltip title fallback also widened so the on-hover hex code shows#00000000for a Clear spool instead of misreporting it as#000000. What is intentionally NOT changed: the native<input type="color">value inSpoolBuddyWriteTagPage.tsx's simple-view picker keeps its 6-char hex — that input element doesn't support alpha, and its onChange handler still sets rgba back to opaqueFF(which is correct behaviour: the user explicitly picked a colour via the picker, not transparency). The colour-sort comparator inLabelTemplatePickerModal.tsx::colorSortKeykeeps its 6-char alpha-strip — transparent spools sort into the same bucket as black/neutrals which is the right behaviour for ordering. The label-renderer inbackend/app/services/label_renderer.pykeeps its 6-char alpha-strip in_hex_code_labelbecause the printed text on a physical label can't show transparency —_color_from_hexdoes honour the alpha byte for the printed swatch fill (alpha=00 → invisible swatch on the label, which is the honest physical answer). The Spoolman auto-sync's_find_or_create_filamentinbackend/app/services/spoolman.pystill strips alpha when looking up the Spoolman catalog because Spoolman's filament catalog schema only supports 6-charcolor_hex— a transparent AMS spool synced into Spoolman will now match against a000000(Black) Bambu Lab filament entry instead of the pre-fix synthetic "PLA Basic" cream entry (RGBF5E6D3); both are inaccurate, the post-fix behaviour is at least honest about which colour the catalog has chosen rather than silently inventing a cream spool — users on the Spoolman backend can manually correct the filament assignment if desired. (g) Removed the cream rewrite atbackend/app/services/spoolman.py::parse_ams_traythat silently replaced AMS-reported00000000withF5E6D3FF("Light cream/natural color"). That rewrite was a workaround from when the swatch renderer couldn't show alpha —filamentSwatchHelpers.ts::buildFilamentBackgroundalready paints a checkerboard underlay for alpha < FF (added in #1154), so the rewrite has been hidden technical debt that made every AMS-detected transparent spool land in inventory as cream instead of clear, with no signal to the user that a colour was substituted. AMS-synced spools now keep their true00000000value; the swatch renders the checkerboard;getColorNameresolves to "Clear". (h)backend/app/services/spool_tag_matcher.py::create_spool_from_trayshort-circuits the colour-catalog lookup whenrgbais alpha=00 and storescolor_name="Clear"directly — without this, a Bambu-RFID transparent spool would resolve against the#000000row in the catalog (orBlackvia the HSL fallback) before the frontend's name resolver ever sees it, defeating the alpha-aware fix incolors.ts. Tests —src/__tests__/utils/colors.test.ts: 5 new assertions covering alpha=00 → "Clear" forhexToColorName,getColorName(including precedence over a catalog entry on the same RGB), andresolveSpoolColorName; one existing assertion changed from12345600(which now correctly resolves to "Clear") to123456FFto keep its intent of "unknown opaque colour returns null".src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx: header docblock rewritten to reflect the new 0–8 char draft contract; the "truncates 7–8 char pastes to RGB" test replaced with two new tests —'0011223344'paste now truncates to the leading 8 chars (00112233) and commits verbatim with noFFappend, and a 7-char draft on blur pads to 8 with a trailing0instead of jumping back to RGB. 17 colours tests, 9 hex-input tests, 53 useFilamentMapping tests, 14 FilamentOverride tests, 10 FilamentSlotCircle tests, 6/printers/available-filamentsintegration tests, 50 Spoolman API integration tests all green. Backend ruff clean; frontend build clean; i18n parity clean at 4998 leaves per locale. What this does NOT change: Spoolman-mode parity is preserved — Spoolman's own picker already supported 8-digit hex andinventory.py:119/spoolman.py:887-889already passed00000000through verbatim (the 6→8 charFFpad only fires whenlen == 6), so no parallel mutation is needed on the Spoolman-mode write path. Existing inventory rows that were already rewritten toF5E6D3FFstay as cream until the next AMS sync overwrites them — a one-time edit is the only path to recover them, and dropping the rewrite means future AMS syncs land the true value. - Virtual-printer MQTT no longer drops idle slicer connections at exactly 60 s (#1548, reported by hollajandro) — Reporter pointed OrcaSlicer at a Bambuddy virtual printer and got a clean MQTT/TLS connect, successful auth, and a normal pushall/get_version exchange — then the slicer dropped exactly ~60 s later, every time, even after a fresh trust of the VP CA, a logged-out Bambu account, and toggling VP mode. Trace from his support bundle: 5 consecutive connect→disconnect cycles all exactly 60 s apart, with no intervening client packets after the initial exchange. Root cause:
backend/app/services/virtual_printer/mqtt_server.py::_handle_clientused a hardcodedtimeout=60on every per-packet read, and_handle_connecttwo functions below explicitly skipped the keepalive field from the CONNECT payload (# Skip keepalive/idx += 2). So no matter what the client negotiated, the VP server would close the socket after 60 s of silence — and OrcaSlicer's normal pattern after the initial exchange is to sit quietly waiting for the printer to push status updates, which a virtual printer with no real state changes doesn't do. The real Bambu firmware honours the client's keepalive (MQTT spec §3.1.2.10 / §4.4: server must allow 1.5× the negotiated value before disconnecting), which is why Orca works against a real P1S but failed at exactly 60 s against the VP. Fix:_handle_connectnow parses the 2-byte big-endian keepalive value from the CONNECT payload and returns it alongside the auth bool (tuple[bool, int])._handle_clientuses that to set its per-packet read timeout to1.5 × keep_aliveafter a successful CONNECT, orNone(no timeout) when the client opted out withkeep_alive == 0per spec. The 60 s default is retained for the initial read before CONNECT arrives, so a TCP-connect-but-never-send still gets reaped. Tests: 7 intest_vp_mqtt_server.py—TestHandleConnectKeepalive(4: returns negotiated value on success, returns 0 for opt-out, returns(False, 0)on auth fail / parse error so the caller's tuple-unpack never crashes),TestHandleClientHonoursKeepalive(3: idle client withkeep_alive=180is still alive past the old 60 s boundary;keep_alive=2closes idle in ~3 s; a PINGREQ inside the window resets the timeout and the connection exits via DISCONNECT instead of timeout). The integration-style tests feed a synthetic CONNECT into a realasyncio.StreamReaderand drive the handler on an event loop, so the timeout math is exercised end-to-end, not just unit-mocked. Backend ruff clean. - A1 no longer auto-replays the previous print after a power cycle when the library row's filename has a doubled
.gcode.3mf(#1542, reported by vixussrl-ui) — Reporter has seven A1s powered through Tuya smart plugs + Home Assistant. After every plug-driven auto-off, turning the printer back on would sometimes start the previous print on its own. Trace from his support bundle: the library row in his DB hadarchive.filename = "Cube (1).gcode.3mf.gcode.3mf"— the.gcode.3mfsuffix had been appended twice somewhere during the file's import. The dispatcher'sarchive.filename→ SD-card-name derivation only stripped ONE trailing.gcode.3mf, so the upload landed at/Cube_(1).gcode.3mf.3mf. The print ran fine, but the post-print SD cleanup inmain.pyderived its delete target fromsubtask_name + ext(/Cube_(1).3mf,/Cube_(1).gcode) — neither matched the actually-uploaded path, both 550'd three times, and the real file lingered on the SD card. On next power-up the A1 firmware picked up the leftover .3mf at the SD root and started printing it, exactly like the P1S behaviour the original Issue #374 cleanup was meant to prevent. Two structural fixes, both shipped together (no follow-ups per [[feedback_no_followups]]): (1) shared name derivation. Newderive_remote_filename(filename)helper inbackend/app/utils/filename.pyiteratively strips trailing.gcode.3mf/.3mfsuffixes until the bare stem remains, then appends a single.3mfand underscore-replaces spaces (the firmware parsesftp://{filename}as a URL, spaces break it). Iterative strip handles the doubled-suffix data; the previous single-iteration strip silently fell through to "append .3mf to whatever's left", which is how doubled extensions ended up on the SD card in the first place. The helper is the single source of truth for the SD-card target name — three previously-duplicated upload sites now route through it:_run_reprint_archiveand_run_print_library_fileinbackend/app/services/background_dispatch.py, and the queue dispatch inbackend/app/services/print_scheduler.py. (2) cleanup uses the same algorithm as upload. The post-print SD cleanup inmain.pynow fetchesarchive.filenamewhenarchive_idis resolved and triesderive_remote_filename(archive.filename)FIRST, with the legacy/{subtask_name}.3mfand/{subtask_name}.gcodepaths kept as fallbacks for archive-less prints (subtask never matched any archive) and for older naming variants. De-duped when the primary target equals one of the fallbacks, so the happy-path delete count is unchanged. On the reporter's case the new primary candidate is/Cube_(1).gcode.3mf.3mf, matching the on-card file and deleting it cleanly — no more ghost print. Out of scope (separate concern): the upstream import path that produced the doubled.gcode.3mf.gcode.3mffilename is not addressed here — the iterative strip inderive_remote_filenamedefends against it everywhere it matters (upload target, cleanup target), so any future user with the same legacy data still gets clean dispatch and cleanup. Defensive hardening caught in the first integration run: the initial helper had no input type check, just awhile Truestrip loop withendswith/ slice. When a unit test mock (unittest.mock.MagicMock) was passed in by accident via the new cleanup path,mock.endswith(".gcode.3mf")returned a truthyMagicMockon every iteration and the slicestem[:-10]returned anotherMagicMock— the loop never reached theelse: breakbranch. Each iteration allocated a freshMagicMockuntil the LXC cgroup OOM-killer reaped the pytest worker at 61 GB anon-rss (visible injournalctl -kasoom_memcg=/lxc/109withCONSTRAINT_MEMCG). Fixed by adding anisinstance(filename, str)guard that raisesTypeErrorinstead of entering the loop — turns the silent infinite allocation into a loud, debuggable error. The same guard protects production: if a corrupt DB row or ORM edge case ever surfaces a non-strarchive.filename, the cleanup logs a warning via its outertry/exceptinstead of OOMing the backend. Tests: 10 inTestDeriveRemoteFilenameintest_filename_validation.py(single.gcode.3mfstrip, single.3mfstrip, bare stem appends.3mf, space→underscore, the literalCube (1).gcode.3mf.gcode.3mfreproducer from #1542 →Cube_(1).3mf, doubled.3mf.3mf, mixed.gcode.3mf.3mf, raw.gcodepreserved as.gcode.3mfsince.gcodealone is a valid sliced file, idempotence — running the helper on its own output is a no-op, Unicode stem preserved, type guard —MagicMock/None/intinputs all raiseTypeErrorwith a clear message instead of entering the loop). 315 dispatch + print-complete-path tests green (test_phantom_print_hardening.py,test_print_start_assigns_printer_id_to_vp_archive.py,test_print_start_expected_promotion.py,test_cost_tracking.py,test_print_queue_api.py'sTestAbortedStatusNormalisation— which was the suite that originally OOM'd, now passes in 2 s serial / 12 s under-n 30). Backend ruff clean. - Print filenames with FAT32-illegal characters now rejected at rename/upload/queue time instead of failing at FTP (#1540, reported by anthonyma94) — Reporter could rename a library file to
L|R.3mf, and the PUT/library/files/{id}endpoint accepted it becauselibrary.py:4011only blocked/and\. The pipe (and the rest of the FAT32/exFAT-illegal set< > : " / \ | ? *, control chars, trailing dots/spaces) flowed through to FTP upload time, where the printer's SD card rejected the create with553 Could not create file— far from the rename action that caused it. Bambu Studio refuses these names client-side in its save dialog; Bambuddy now does the same. Fix: newbackend/app/utils/filename.pyexportingvalidate_print_filename(name)andInvalidFilenameError— single source of truth for the rejected set (Bambu-Studio-parity: the nine chars above, control codes 0x00-0x1F, empty/whitespace-only, bare./.., trailing space or dot, and 255 UTF-8 bytes max). Wired into three boundaries: (a)update_fileatlibrary.pyreplaces the path-separator-only check; (b)upload_fileatlibrary.pyrejects bad multipart-upload filenames before they're persisted; (c)print_library_fileadds a pre-flight check so older library rows that pre-date the rename validation fail with an actionable 400 instead of an obscure FTP 553; (d)add_to_queueatprint_queue.pysame pre-flight so queued files don't sit waiting just to fail at dispatch. The print/queue checks deliberately refuse rather than auto-rename — silently rewriting user filenames was the wrong UX (Studio doesn't, and the user explicitly chose that name). Existing rows with illegal names are left alone; users see a clear error pointing at rename. Frontend: the rename modal inFileManagerPage.tsxnow mirrors the same character set client-side, shows the offending char inline as a red error below the input, and disables the Rename button while invalid — matches Bambu Studio's instant feedback rather than a round-trip-to-400. i18n: newfileManager.invalidFilenameCharkey with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per [[feedback_translate_dont_fallback]]; parity script clean at 4998 leaves per locale. Tests: 26 intest_filename_validation.py(parameterised over every char inINVALID_FILENAME_CHARS, the exactL|R.3mfreproducer from the bug, empty/whitespace/./.., control chars, trailing space/dot, byte-length cap with multi-byte UTF-8 to verify it's bytes not codepoints). Backend ruff clean; frontend build clean. - Fallback archives now carry MQTT-derived filament type + colour when the 3MF can't be downloaded (#1533, reported by JmanB52D) — Reporter (lead of a maker-space 3D Fab area) was evaluating Bambuddy partly to count filaments per print for AMS expansion planning; print log was showing "—" in the filament column for every job. Trace: a P2S in VP proxy mode where the slicer's .3mf upload lands on the real printer's SD card, then the printer locks the file mid-print and refuses every FTP read (the existing fallback-archive code path in
main.py:2596, originally added for P1S/A1 printers, anticipates this: "FTP has file size limitations" — same effective behaviour on P2S). The user log shows ~12 FTP candidate paths attempted on every print start, every one returning 550, then directory listings on/cache /model /data /data/Metadataalso returning 550, then the fallback archive being created withfile_path=""and every filament column NULL — even though the MQTT print-start payload already had the AMS state and the slicer's slot-per-print-filament mapping sitting indata["ams"]["ams"]/data["ams_mapping"]. Fix: new_extract_filament_data_from_mqtt(data, ams_mapping)helper inbackend/app/main.py(placed next to the existing_get_start_ams_mapping) walksdata["ams"]["ams"][*].tray[*]to build a global-tray-id → (tray_type, tray_color) map, then narrows to slots referenced byams_mappingif present (slicer order preserved; -1 entries for VT-tray skipped), or falls back to every loaded slot otherwise. Output is a comma-separatedfilament_type+filament_colorin the same shape the 3MF extractor produces — so the inventory page, Quick Stats filament rollup, andlen(filament_type.split(','))per-print count all light up identically for fallback rows. Truncated to the model's column limits (50 / 200). Defensive against malformed MQTT shapes (non-dict entries, non-int ids, missing fields) since this runs in the print-start hot path and a raise would break print logging entirely. The fallbackPrintArchive(...)constructor now passesfilament_type=/filament_color=from the helper. What this is NOT: not per-filament gram usage (that needs the 3MF'sslice_info.configor a deep AMS layer-delta integration viausage_tracker) — only types and colours. The user explicitly asked for "the number of filaments used to know if or when we need to expand AMS units", which is exactly what this gives them (SELECT COUNT(DISTINCT split(filament_type, ',')) ...or the existing inventory count surfaces). A separate, larger piece of work to capture the .3mf in VP proxy mode at upload time (by sniffing FTP STOR intcp_proxy.py) is the real long-term fix for any user who wants full 3MF-derived archive metadata in proxy mode; it's not bundled here. Tests: 15 intest_fallback_archive_mqtt_filament.py(backend/tests/unit/) covering: empty / malformed / no-loaded-slot payloads return{}; the no-mapping path lists every loaded slot in ascending global-id order with colours uppercased; anams_mappingfilters to and reorders by the slicer's order; VT-tray sentinels (-1) are filtered; dual-AMS layouts resolveunit*4 + traycorrectly across units; a mapping pointing at unknown slots falls through to the known subset, but an entirely-unknown mapping returns{}rather than misreporting from the all-slots fallback; both column-limit truncations enforced; missing-colour-but-present-type emitsfilament_typeonly; defensive against non-dict/non-int garbage in the AMS list without raising. Existing 22 print-start unit tests untouched and green. Backend ruff clean. - SpoolBuddy: Tare status banner no longer sits at "Waiting for device..." forever (#1536, reported by flom89) — On the SpoolBuddy kiosk's Settings → Scale (Waage) tab, pressing TARE wrote the "Tare command sent. Waiting for device..." banner but had no mechanism to resolve it. The daemon writes back through
POST /spoolbuddy/devices/{id}/calibration/set-tare(which stampstare_offset+last_calibrated_aton the device row), the device list query already polls every 10 s, buthandleTareinfrontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsxwas set-and-forget — the banner persisted indefinitely. The "Calibration complete!" banner on the full calibration flow had the same shape and stayed forever too. Fix: a completion watcher that snapshotsdevice.last_calibrated_atwhen TARE is pressed, sets anawaitingTareSincestate, invalidates the device-list query every 1 s while that state is active (so detection responds within ~1 s instead of waiting on the 10 s background poll), and whenlast_calibrated_atadvances past the snapshot flips the banner to "Tare complete!" with a 3 s auto-dismiss timer. A 15 s timeout on the watcher fails open to "Tare timed out — is the SpoolBuddy daemon running?" so a dead daemon doesn't leave the user staring at the spinner. The Calibration-complete success banner and the calibration-failed error banner now share the same auto-dismiss helper (3 s success, 5 s error). All timers are owned by auseRefthat cleans up on unmount; pressing TARE while a previous dismiss is queued cancels the old timer. i18n: two new keys (spoolbuddy.settings.tareComplete,spoolbuddy.settings.tareTimedOut) translated into all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per [[feedback_translate_dont_fallback]] — no English fallbacks. Parity script passes at 4997 keys × 9 locales. Frontend build clean. - ntfy notifications: honest User-Agent + actionable error when the server is behind a Cloudflare challenge (#1534, reported by apizz) — Reporter pointed an ntfy server behind a Cloudflare Tunnel at Bambuddy and got
HTTP 403: <!DOCTYPE html>...Just a moment...on every Test click. They reproduced the same response with a plaincurl -H "Authorization: Bearer <token>" -d "test" https://ntfy.example/<topic>— confirming the 403 originates from Cloudflare's JS challenge intercept (Bot Fight Mode / "Under Attack" mode), not from Bambuddy or ntfy. Cloudflare returns its interstitial HTML to any non-browser client at the edge, so the request never reaches the user's ntfy backend at all. Bambuddy can't solve a JS challenge from a backend — the only real fix is on the user's Cloudflare side (a security-skip rule for the hostname/path, disabling Bot Fight Mode for that hostname, or fronting the server with Cloudflare Access using a service token). Two improvements shipped to make this footgun self-diagnosable for the next user who hits it. (1) Honest User-Agent on the notification HTTP client.backend/app/services/notification_service.pywas the one outbound httpx client in the codebase that didn't set the project-standardBambuddy/1.0 (+https://github.com/maziggy/bambuddy)UA — it leakedpython-httpx/<version>instead. Brings it in line withbambu_cloud/makerworld/firmware_check/inventory(all unified during the May 2026 compliance pass) and makes Bambuddy a more obvious citizen to upstream WAFs and proxy operators. Won't defeat Cloudflare's JS challenge (the user's curl test proves CF blocks regardless of UA) but it's a consistency / hygiene fix with no regression risk. (2) Cloudflare-challenge detection on the ntfy error path. New_looks_like_cloudflare_challenge(response)helper checks the response shape (Server: cloudflareorcf-mitigatedheader, or<!DOCTYPE html>...Just a moment...body). When a 403/non-success response matches, the error returned to the UI now reads: "HTTP 403 — ntfy server is behind a Cloudflare challenge. Bambuddy was served the JS challenge page instead of reaching ntfy. Cloudflare cannot be solved from a backend; add a Cloudflare security-skip rule for this hostname, disable Bot Fight Mode, or front the server with Cloudflare Access using a service token. (#1534)" — actionable, points at the real fix, removes the raw HTML dump. A regular 403 (e.g. ntfy auth failure with a plainforbidden: invalid auth tokenbody) still surfaces the original body so genuine auth errors stay debuggable; the interceptor only fires on the Cloudflare shape. Tests: 3 new inTestNtfyOutboundintest_notification_service.py— (a) the lazy-constructed httpx client carries the honest UA header on first use; (b) a 403 withServer: cloudflare+Just a moment...body produces the actionable error and does not echo<!DOCTYPEto the user; (c) a 403 with a plain text auth-failure body keeps the originalHTTP 403: forbidden: invalid auth tokenso we don't hide real errors. 110/110 in the notification suites green underpytest -n 30. Backend ruff clean. - Source-3MF upload on "fallback" archives no longer crashes with HTTP 500 (and stops orphaning files outside the data volume) (#1531, reported by d3nn3s08) — When MQTT reports a print start but Bambuddy never saw the source 3MF (cloud-initiated prints, Bambu Handy, prints already on the printer's SD card when Bambuddy connected),
main.py:2596creates a "fallback"PrintArchiverow withfile_path="". The twoArchives → Source 3MF Uploadroutes computed the destination directory as(settings.base_dir / archive.file_path).parent / "source"— which on a fallback row collapsed toPath('/app/data') / '' = Path('/app/data'), whose.parentisPath('/app'), sending the upload to/app/source/<filename>.3mf. The file was physically written there (a path outside the user's mounted data volume — orphaned on container restart) and only the finalsource_path.relative_to(settings.base_dir)raised, so every retry left another orphan. Affected reporter is on a QNAP Docker host with the standard/app/datamount; both maintainer and triage initially diagnosed it as a Docker volume misconfiguration, but the traceback shows the bug is purely on Bambuddy's side — the user's setup was correct. Fix: new private helper_resolve_source_3mf_path(archive, source_filename)inbackend/app/api/routes/archives.pycentralises the destination computation. Normal archives still nest the source under<archive_file_dir>/source/<filename>. Fallback archives (emptyfile_path) now land under<base_dir>/archive/no_source/<archive_id>/<filename>instead — a deterministic, addressable location that stays inside the data volume, and the existing read sites (download_source_3mf,download_source_3mf_by_filename, the slicer-token routes,delete_source_3mf) all continue to work because they read back viasettings.base_dir / archive.source_3mf_path. The helper also defensively asserts the resolved directory is insidebase_dir.resolve()regardless of where it came from, so a row corrupted by an old import or a manual SQL edit fails with a clear 500 message ("Archive N resolves to a path outside the data directory; cannot attach source.") instead of silently writing outside the volume. Both upload sites (upload_source_3mfandupload_source_3mf_by_name, the slicer-post-processing endpoint) now route through the helper, so neither can independently drift back into the bug. Tests: 2 new inTestUploadSourceThreeMFinbackend/tests/integration/test_archives_api.py— (a)test_fallback_archive_source_upload_lands_under_base_dircreates an archive withfile_path="", uploads a minimal valid 3MF, asserts 200 status, that the returnedsource_3mf_pathis relative (not/app/source/...), that the file physically exists under the patchedbase_dir, and that the path is the deterministic fallback location keyed offarchive.id; (b)test_normal_archive_source_upload_unchangedis the same flow against an archive with a populatedfile_path, asserting the existingarchives/test/source/<filename>.3mflayout is preserved (regression guard against the helper accidentally changing the normal path). 57/57 intest_archives_api.pygreen underpytest -n 30. Backend ruff clean. Note: existing orphan files at/app/source/<filename>.3mffrom prior failed retries inside an affected user's container can be safely deleted; they were never indexed in the DB, never reachable from the UI, and would have vanished on the next container restart anyway. - SpoolBuddy weight sync no longer silently lands on a stale local row when Spoolman is enabled (#1530, reported by chesterakl) — Reporter (Spoolman mode, H2C, internal "manually add then NFC-link" flow) saw the SpoolBuddy "Sync Weight" button flip to "Synced!" but the Spoolman-backed inventory listing never updated. Cause:
POST /spoolbuddy/scale/update-spool-weight(backend/app/api/routes/spoolbuddy.py) ran the lookup local-DB-first and only fell through to Spoolman on local miss — but the upstreamnfc/tag-scannedroute is exclusive (always-Spoolman whenspoolman_enabled=true, after the #1119 / nfc-routing fix). When the user's local DB still held a staleSpoolrow that happened to share a numeric id with the Spoolman spool the NFC tag mapped to, the sync endpoint absorbed the update into the stale local row, returned 200 with the localweight_used, and the actual Spoolman spool went untouched. The support log confirms it: 17 sync attempts across two days, every line loggedSpoolBuddy updated spool 2 weight: …g on scale, …g used(the local-branch log format) and theSpoolBuddy updated Spoolman spool …line (which only fires in the Spoolman branch) never appeared. The bug couldn't be reproduced on developer setups because they don't carry a leftover local row with a colliding id. Fix:update_spool_weightnow routes exactly likenfc_tag_scanned—_get_spoolman_client_or_none(db)first, and that result picks the branch exclusively. Spoolman mode goes straight to Spoolman with no local-DB read; local mode does the local update and returns 404 (not "fallback to Spoolman") on a local miss. Matches [[feedback_inventory_modes_parity]] — the two inventory modes must behave identically from the user's perspective, including which row gets written. The docstring now spells out the routing contract so the next reader doesn't reintroduce the local-first read. Tests: 1 new regression test inTestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman— creates a localSpoolwith the same numeric id as a mocked Spoolman spool, posts the sync, asserts (a) Spoolman'supdate_spoolwas called with the correct remaining weight, and (b) the local row'sweight_usedandlast_scale_weightare unchanged after arefresh()against the live DB. The existing 8 tests in that class continue to assert the Spoolman branch math (filament/spool-level tare priority, 404 / 503 mappings, 250g fallback warning). 9/9 green; 126/126 across the spoolbuddy + spoolman-filament-patch integration suites green underpytest -n 30. Cleanup hint for affected users: anyone in Spoolman mode with leftover local Spool rows from before they switched should delete those rows — they're inert under the new routing, but they were eating sync attempts under the old. Backend ruff clean. - Paused prints no longer inflate maintenance hours (#1521, reported by TempleClause) — The
track_printer_runtimebackground task inbackend/app/main.pycounted bothRUNNINGandPAUSEstates equally towardruntime_seconds, which feeds every hours-based maintenance interval (lubricate rods, clean nozzle, check belts, etc.). Maintenance items measure mechanical wear, and pause time involves no motion — so a print paused overnight stretched the maintenance clock forward by ~8 h without any actual wear, triggering "lubricate rods" warnings earlier than warranted. Reporter found this by code review (no support bundle), flagged it cleanly with the exact line inmain.pyand three ranked solution options. Fix: option 1 (exclude PAUSE entirely) —state.state in ("RUNNING", "PAUSE")→state.state == "RUNNING". PAUSE now follows the same path as FINISH / IDLE / PREPARE: the elapsed-time accumulator skips it, andlast_runtime_updateis cleared so a later RUNNING transition starts fresh and doesn't back-bill the pause. No setting / toggle (reporter's option 3 was deliberately the throwaway — this is a wear-tracking semantic, not a user preference); no cap (option 2) — wear during pause is zero, not "reduced". Docstring and field-comment trail updated acrossmain.py,models/printer.py:23, and the twoapi/routes/maintenance.pyroute docstrings that all previously described the field as covering "RUNNING and PAUSE states". Out of scope: retroactive backfill of existingruntime_secondsvalues — already-accumulated pause time cannot be split out, only future accumulation is fixed. Users with hours-based maintenance intervals already set will see slower accumulation going forward (the correct outcome), so a previously-near-due item may take longer to ring than under the old behaviour. Tests: 3 new intest_runtime_tracking_pause.pypinning the new contract — PAUSE does NOT accumulate and clearslast_runtime_update; RUNNING still accumulates and updates the timestamp; a non-active state (FINISH) clearslast_runtime_updateto prevent back-billing the idle time when the printer next goes RUNNING. The tests drive the actualtrack_printer_runtime()coroutine through a single iteration via patchedasyncio.sleepagainst an in-memory SQLite DB, so they catch any regression in the predicate at the call site (not just an extracted helper). Backend ruff clean; targeted 24-test rod/runtime subset all green. - Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by IndividualGhost1905) — Reporter saw
Total prints: 20 / Success: 18 / Failed: 1and asked where the 20th print went; the breakdown only showed Successful + Failed, so a cancelled run silently inflated the total without appearing anywhere. The earlier #1390 round had committed a test that locked in the bug —it('uses total_prints as denominator so cancelled/stopped events count')asserted the gauge should divide bytotal_prints, which lumped user/queue-cancelled jobs in with quality outcomes and conflated user intent with printer performance. Cause:PrintLogEntry.statushas six values in production (completed,failed,aborted,stopped,cancelled,skipped) but the Quick Stats endpoint inapi/routes/archives.pyonly counted two —completed→ Successful,status == "failed"→ Failed — and used a rawcount(*)for Total Prints, so the other four statuses ended up in Total without surfacing in any breakdown row.abortedwas particularly silent: classified as a failure elsewhere in the codebase (failure_analysis.py,main.py:430,1729) but not counted towardfailed_printsin stats. Fix: three-bucket classification across the whole stats surface, matching how the rest of the codebase already groups these statuses. Quick Stats now returnssuccessful_prints(completed),failed_prints(failed + aborted — printer-detected quality failures), and a newcancelled_prints(stopped + cancelled + skipped — user/queue interruptions). The SuccessRateWidget gauge divides bysuccessful + failedonly, so cancelling a roll because you changed your mind doesn't ding the printer's success rate — a Cancelled row in the breakdown surfaces the count so it doesn't silently vanish from Total Prints. The Failure Analysis service applies the same denominator change (failure_rate = failed / (successful + failed)) to both the headline rate and the per-week trend, so a week with no failures but several cancellations no longer reads as a misleading 0/N. Schema change is additive-safe:ArchiveStats.cancelled_printsdefaults to0so any historical fixture validating against the model still parses; the frontend type also defaults the display to0when the field is missing. i18n: newstats.cancelledkey with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) per [[feedback_translate_dont_fallback]]; parity script clean at 4994 leaves per locale. Tests: existingit('uses total_prints as denominator …')test inverted to assert the new behaviour (40 completed / 20 failed / 35 cancelled → gauge shows 67%, Cancelled row reads 35),cancelled_prints: 0added to the shared mock so the unchanged-display assertion (140/150 → 93%) still holds since140 / (140 + 10) = 93.33%rounds identically. 33 StatsPage tests + 6 backend stats/failure tests green; frontend build + backend ruff clean. Follow-up (cosmetic): the new Cancelled row's Ban icon rendered intext-bambu-graywhile the Successful and Failed icons used semantictext-status-ok/text-status-errortokens — reporter (IndividualGhost1905) noted the asymmetry and asked for an orange to match what Archives + notification badges use for cancelled. Switched the Cancelled row totext-status-warning(amber-500, same token family as the other two rows), so all three icons are now semantic-token-driven and the new row matches the colour the user already associates with cancelled status elsewhere in the UI. - VP queue mode no longer blocks BambuStudio Send while the target printer is mid-print (#1558, reported by phieb) — Reporter set up a non-proxy queue-mode VP with a target printer bound, started a print on the real printer, then tried Send to the VP from BambuStudio — slicer refused with the "busy" pre-flight error even though Bambuddy's whole job is to look idle so jobs queue any time. Cause traced by reporter:
SimpleMQTTServer._send_status_reportforcesgcode_state=IDLEand storage indicators on top of the cached-as-base mirror — good — but the cached branch overrode only a handful of fields, and the live print-progress fields from the mirrored realpush_status(mc_print_stage, mc_percent, mc_remaining_time, stg, stg_cur, layer_num, total_layer_num, print_error) passed through unchanged. The VP emitted a contradictory report (gcode_state=IDLE but mc_percent>0, stg_cur>0, ...) and BambuStudio's Send pre-flight read it as busy. Without a bound target the synthetic-stub branch reported all of these idle and Send worked — isolating the leak to the cached branch. Fix: in the cached branch, also override those 8 activity fields to the idle values the synthetic-stub branch uses (mc_print_stage="",mc_percent=0,mc_remaining_time=0,stg=[],stg_cur=0,layer_num=0,total_layer_num=0,print_error=0). Same shape as the #1228 storage-indicator overlay — internally consistent with the forced IDLE state while AMS / version / temperatures keep mirroring. Behavioural caveat for users: a slicer connected to the VP just for monitoring no longer sees the real printer's mid-print progress through the VP (since the cached push now reports idle). The real printer's IP / UI remains the source of truth for progress. Per the issue intent, this trade-off is explicit. Tests: newtest_live_progress_fields_zeroed_in_cached_branchintest_vp_mqtt_bridge.py::TestStatusReportCachedAsBase. - VP
_pending_files/ temp-file leak on every error path across the three file handlers — Pre-fix:_archive_file,_queue_file, and_add_to_print_queueonly popped_pending_filesand unlinked the temp file on the success branch. When archival failed (DB outage, ArchiveService raise, queue insert error), the entry stayed in the dict — and since the FTP layer keys its "same-name STOR already in flight" guard on filename, the slicer's next retry was spuriously rejected; the upload_dir also accumulated orphan temp files indefinitely. Each handler now uses atry / finallythat pops the marker and unlinks the temp file regardless of whether the body succeeded. 3 unit tests intest_virtual_printer.py::TestVirtualPrinterInstance(one per handler) inject a failure mid-flight and assert both invariants. - VP queue position now picks
MAX(position)+1instead of hardcoded1— Pre-fix: VP-uploaded queue items always landed atposition=1. With non-empty queues this created duplicate position=1 rows; the scheduler orders by(printer_id, position)so ties resolved in undefined DB-internal order, and repeat VP uploads accumulated multiple position=1 rows — making the queue's visible ordering non-deterministic and dispatching out of the user's intended sequence. Now the VP path runs the sameSELECT MAX(position) FROM print_queue_items WHERE printer_id=<target or NULL> AND status='pending'query the canonicalPOST /print-queue/route uses and inserts atmax_pos + 1. Defensivetry/exceptaround the.scalar()call so a mocked DB in tests can't cause aTypeErrorfrom MagicMock arithmetic. 1 unit test pins the MAX+1 behaviour (withMAX=7the inserted item lands atposition=8). - VP DELETE route cleans orphan
PendingUploadrows + on-disk upload_dir — Pre-fix:DELETE /virtual-printers/{vp_id}stopped the running instance and removed the row, but thebase_dir/uploads/<vp_id>/directory and anyPendingUploadrows that referenced it lingered. The user only learned the rows were orphaned by trying to archive one and getting a "file missing" → flip-to-discarded auto-handler — not exactly a clear signal. Now the DELETE handler queriesPendingUploadrows whosefile_pathstarts with the VP's upload_dir prefix, marks themstatus='discarded', thenshutil.rmtrees the directory after the DB commit succeeds (so a crash between commit and rmtree leaves orphan files at worst, not orphan rows pointing at a missing tree). 2 unit tests intest_vp_delete_cleanup.pycover the cleanup-with-orphans + clean-no-op paths. - VP
MQTTBridge._refresh_loopcrash no longer leaks the raw_message_handler — Pre-fix: if any exception escaped_resolve_client(the IP-encoding branch was the most likely culprit),_refresh_loopcaught it withlogger.exceptionand returned. The task completedstatus=done— not cancelled, not raising — sostop()never ran and_unbind_clientnever fired.self._on_printer_rawstayed registered on the liveBambuMQTTClientand kept reading / writingself._latest_print_stateon every real-printer message even though the VP bridge was functionally dead, creating a behaviour leak that persisted across VP restart. Now the crash exit explicitly calls_unbind_client()so the orphaned handler is detached even when the loop dies abnormally. - VP
sync_from_dbserialised byasyncio.Lock(concurrent-PUT race) — Two simultaneousPUT /virtual-printers/{id}calls (e.g. browser racing the auto-save trigger) could race the inner start/stop sequence and leave duplicate sub-services bound to the same port — split-brain state that only resolved on the next Bambuddy restart.VirtualPrinterManager.__init__now holds a_sync_lock;sync_from_dbwraps the body inasync with self._sync_lock. Single VP updates still complete in well under a second, so the serialisation isn't visibly slower. - VP
_slicer_print_optionscache bounded at 128 entries with FIFO eviction — Pre-fix: the dict that stashes the slicer'sproject_fileoptions (so_add_to_print_queuecan inherit timelapse / bed_leveling / flow_cali / etc.) had no bound. If the slicer sentproject_filefor a filename whose FTP upload was rejected / cancelled / non-3MF, the stash was orphaned and the dict grew one entry per such event for the VP's entire uptime. The new bound triggers eviction of the oldest entry once 128 entries accumulate. - VP
MQTTBridgesticky-key carry-forward now usescopy.deepcopy— Pre-fix: a sticky key carried over from the previous cache was assigned by reference, sharing nested dicts/lists between the old and new state. No current code path mutates a carried-forward sticky key in place, so this was latent — but a future merge that did would corrupt both copies. Defensivecopy.deepcopyon the carry-forward removes the foot-gun without changing observable behaviour. - VP
MQTTBridge._refresh_loopandSimpleMQTTServer._send_status_reportcached-path use deepcopy —_send_status_reportcached branch was usingdict(cached)— a shallow copy. Today's mutations are top-level only, but a future override that wrote into a nested dict (e.g.online,upgrade_state,ipcam) would corrupt the bridge cache and be read by every subsequent subscriber until the next real-printer push landed. Switching tocopy.deepcopyremoves the foot-gun. - VP
SlicerProxyManagerlifecycle hardening — Multiple proxy-mode fixes shipped together: (a)_ftp_data_proxiesand_actual_ftp_portare pre-initialised in__init__instead ofstart(), sostop()called beforestart()finishes (rapid mode-switch race) no longer raisesAttributeErrorand leaves sockets stranded; (b)_actual_ftp_portnow tracks the iptables-redirect target when the deployment usesREDIRECT --to-portto let non-root containers serve on 990, andget_status()returns it — diagnostic was previously probing the class constant 990 and false-failing on every working redirect deployment; (c) the FTP-data-proxyauto_closetasks (101 of them inFTPTLSProxy) are now tracked on_auto_close_tasksand cancelled instop()— previously they lingered ~60 s holding server references and could fail the next start with "address already in use"; (d) probe serversawait server.wait_closed()on stop instead of justsrv.close()— same rapid-restart race. - VP diagnostic now probes both bind ports 3000 and 3002 — Pre-fix: non-proxy bind diagnostic only probed 3002. The bind server in server mode actually listens on both (plain on 3000, TLS on 3002 per
bind_server.py:BIND_PORTS); a VP whose plain listener failed to start but TLS listener succeeded would pass the diagnostic while being half-broken. Nowport_bindreportspassonly when both probes succeed. NewPORT_BIND_PLAIN = 3000constant. - VP FTP
stop()awaits cancelled sessions instead ofsleep(0.1)— A session mid-write, mid-TLS-handshake, or holding a 60 s data-read could easily outlive the 100 ms sleep, and the server'sclose()would run while underlying sockets were still in use. Nowstop()cancels each session task andasyncio.gathers them withreturn_exceptions=True. Stop is a few ms slower in the typical case; worst-case bounded by whatever asyncio takes to propagate cancellation. - VP child sub-services (FTP / MQTT / Bind / SSDP) expose
readyevent for accurateis_running— See Added section for full description. - VP per-VP TLS certificate auto-regenerates when the shared CA is rotated — Pre-fix:
ensure_certificatesonly checked that the per-VP cert file existed. When the shared CA was regenerated (its expiry within 30 days), per-VP certs on disk were still signed by the OLD CA — slicers that imported the NEW CA failed handshake. The check is now a real signature verification:ensure_certificatesloads the on-disk per-VP cert and the on-disk CA, and verifies the cert's signature against the CA's public key viacryptography.hazmat.primitives.asymmetric.padding.PKCS1v15. OnInvalidSignature(rotation detected), the per-VP cert is regenerated under the current CA. The unit-test driven a real bug in an earlier version of this fix: comparing Subject DN was insufficient because Bambuddy's auto-generated CAs share the same Subject Name ("Virtual Printer CA"), so DN-match returned True even after rotation. 3 tests intest_vp_certificate_rotation.py(reuse-when-issuer-matches, regen-when-rotated, no-CA-returns-False). - VP
tailscale.py::get_statusnow catchesasyncio.TimeoutError— Pre-fix:_run_tailscalecould re-raiseTimeoutErrorafter killing a stuck subprocess. Theexcept OSErrorclause inget_statusdidn't catch it, so the exception propagated all the way to the FastAPI route handler and crashed the VP management UI for any user whose hosttailscaledwas lagging. Now the except clause covers bothOSErrorandasyncio.TimeoutError, returning aTailscaleStatus(available=False, error=...)either way. - VP
certificate.pyCA save uses correct parent directory — Pre-fix:_get_or_create_cacreatedself.cert_dir(the per-VP subdirectory) before writing the CA, but the CA writes targetself.ca_key_path.parent(the shared CA dir — potentially a different path). Latent because the manager pre-creates both directories; surfaced by the path-correctness audit. - VP
_extract_plate_idlogs failures at debug instead of silent — Pre-fix:except Exception: return Noneswallowed any failure to parseMetadata/slice_info.configwithout a log. A malformed 3MF then produced a wrong-plate dispatch with no diagnostic trail. The except now logs at debug so support bundles capture the parse error.