github maziggy/bambuddy v0.2.5b1-daily.20260521
Daily Beta Build v0.2.5b1-daily.20260521

pre-release3 hours ago

Note

This is a daily beta build (2026-05-21). 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

  • Slicer: process & filament profiles filtered by the selected printer (#1325, requested by IndividualGhost1905) — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own compatible_printers list for imported (local) presets, and falls back to the BBL <model> name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. The printer and process dropdowns also default to the preset names embedded in the source 3MF's project_settings.config when those presets are available, instead of always taking the first listed preset. New frontend/src/utils/slicerPrinterMatch.ts (11 unit tests) and extract_embedded_presets_from_3mf (5 unit tests); UnifiedPreset now carries compatible_printers, exposed for the local tier (backend/app/api/routes/slicer_presets.py); the plates endpoints return embedded_printer / embedded_process. Parity green, build clean.
  • Spanish (es) translation (#1243, requested by MiguelAngelLV) — Bambuddy now ships a full European Spanish locale. New frontend/src/i18n/locales/es.ts translates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered in frontend/src/i18n/index.ts and selectable as "Español" in the language picker. The parity checker auto-discovers the file — frontend/scripts/check-i18n-parity.mjs gained an ES_COGNATES allow-list for genuine Spanish cognates and brand/format tokens. Brings the supported-language count to 9 (en / de / es / fr / it / ja / pt-BR / zh-CN / zh-TW). Parity green, frontend build clean.
  • Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by PLGuerraDesigns) — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added BZD: 'BZ$' to frontend/src/utils/currency.ts next to MXN (Americas dollar-prefix grouping); getCurrencySymbol('BZD') returns 'BZ$' and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added in frontend/src/__tests__/utils/currency.test.ts covering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean.
  • Connection Diagnostic — self-service triage for "printer won't connect / won't print" — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (backend/app/services/printer_diagnostic.py) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed via GET /printers/{id}/diagnostic (saved printer) and POST /printers/diagnostic (pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHub config.yml troubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green.

Changed

  • Filament inventory: grouped rows now show group totals (#1368, requested by a user) — With "Group similar" enabled, the collapsed group row showed the values of a single member (the first spool) — so a group of five 1 kg spools displayed "1000 g" instead of the 5 kg it actually held. The group header now aggregates across all members: the table view's Label, Net, Gross, Used and Remaining columns and the grid card's weight figure show group totals, while identity columns (Material, Brand, Colour) and the Cost/kg rate stay per-spool-correct. Per-spool-only fields with no meaningful total (dates, location, note, tag ID) keep showing the representative member's value; the expanded individual rows are unchanged. New aggregateGroupSpool helper in frontend/src/utils/inventoryGrouping.ts with 4 unit tests. Frontend-only — all data was already in the spool list. — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound Trent900.3mf sliced via the X1C bundle (POST /slice with bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — printer_model overridden to Bambu Lab X1 Carbon, printable_area to 256×256 (X1C bed, not H2D's 350×320), printable_height 250 (vs 325), bed_exclude_area populated with X1C's 18×28 corner zone, nozzle_diameter single 0.4 (vs H2D's dual 0.4,0.4), and the full X1C machine_start_gcode sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. Behavioural change: dropped !printerMismatch from the SliceModal isReady predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. Dead-code removal (same drop): with no banner, the source_printer_model field on the /library/files/{id}/plates and /archives/{id}/plates responses had zero consumers; the extract_source_printer_model_from_3mf helper in threemf_tools.py (which opened the 3MF zip and read Metadata/project_settings.config on every plate request) had zero callers. Removed both response keys, both backend extractions, both threemf_tools imports, the helper itself, its 6 unit tests, the source_printer_model field from frontend/src/types/plates.ts (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]]): every t() callsite in SliceModal.tsx had an inline English defaultValue: or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (slice.bundle, slice.bundleNone, slice.bundleAllRequired) that had no corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The slice.printerMismatch key was removed from all 8 locales (banner is gone). Why this matters: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. Tests: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch).

Security

  • idna: bump to >=3.15 to clear CVE-2026-45409 (ReDoS in idna.encode() with crafted Unicode payloads, e.g. "٠" * N or "・" * N + "漢") — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit idna>=3.15 floor in requirements.txt between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via pip-audit clean post-upgrade.
  • PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting "this is disputed by the Supplier because the key length is chosen by the application that uses the library." fix_versions=[] on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: backend/app/core/auth.py:184 auto-generates secrets via secrets.token_urlsafe(64) (~86 chars of entropy, far above any sane minimum) and the file-loaded path at :177 rejects secrets shorter than 32 chars. Added a permanent --ignore-vuln CVE-2025-45768 to .github/workflows/security.yml with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale --ignore-vuln CVE-2026-4539 for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: pip-audit --ignore-vuln CVE-2025-45768 alone reports clean).

Fixed

  • OpenSpoolman-tagged spools are now selectable in the AMS-slot assignment picker (#1122, reported by mithkr) — Reporter runs Bambuddy alongside OpenSpoolman. OpenSpoolman writes a generated NFC tag value into the Spoolman spool.extra.tag field; any spool it had tagged then never appeared in Bambuddy's "Select a spool" picker (LinkSpoolModal), so a spool that was physically unassigned could not be linked to an AMS tray. Root cause: GET /spoolman/spools/unlinked (backend/app/api/routes/spoolman.py) classified a spool as "linked, hide it" purely on presence of a non-empty extra.tag. That was a stale proxy. extra.tag is only an RFID/NFC matching key — both Bambuddy and OpenSpoolman write a tag identifier there, for the same purpose — and its presence says nothing about whether the spool occupies an AMS slot. Bambuddy already has a dedicated ledger for that: the spoolman_slot_assignments table, which models/spoolman_slot_assignment.py itself documents as "the source of truth for Spoolman slot assignments". Fix: get_unlinked_spools now decides assignability from that ledger — a spool is assignable iff its id is not in spoolman_slot_assignments — and ignores extra.tag entirely. Verified the ledger is complete: both link_spool (manual link) and the AMS auto-sync (spoolman.py slot-change persistence) upsert a row for every occupied slot, so nothing genuinely assigned can leak back into the picker. get_linked_spools and find_spool_by_tag keep using extra.tag unchanged — those are genuine tag-match maps and are unaffected. Internal-inventory mode needs no parallel change: it stores tags in its own DB with no Spoolman extra collision, so the OpenSpoolman conflict cannot occur there. Visible behavior change: a spool Bambuddy linked but whose slot assignment was later cleared now re-appears as assignable — correct, since it is genuinely re-linkable. Tests: test_spoolman_api.pytest_get_unlinked_spools_success now asserts a spool with a non-empty OpenSpoolman-style extra.tag but no slot row still appears in the picker; new test_get_unlinked_spools_excludes_slot_assigned seeds a SpoolmanSlotAssignment row and asserts that spool is excluded while a tagged-but-unassigned spool is included. 50 spoolman-API integration tests green; backend ruff clean.
  • Missing-spool-assignment notification no longer false-fires on every Spoolman-mode print (#1473, reported and root-caused by ojimpo) — Reporter on Spoolman mode (AMS 2 Pro, all four trays bound to Spoolman spools via the Assign-Spool UI) got a print_missing_spool_assignment notification on every print start — 13 false positives in 7 days — each flagging trays that were correctly bound. He traced it precisely: backend/app/services/spool_assignment_notifications.py queried only the legacy SpoolAssignment table, never SpoolmanSlotAssignment. In Spoolman mode the legacy table is empty (bindings live in spoolman_slot_assignments, the source-of-truth since #1119), so assigned_global_trays came back empty and every used tray was reported missing. Same class of miss as #1459 (the weight tracker also skipped SpoolmanSlotAssignment) and a [[feedback_inventory_modes_parity]] violation — a check present in both modes was wired only for legacy. Fix: the assigned-tray set is now the union of both tables — SpoolAssignment and SpoolmanSlotAssignment rows for the printer. Both expose printer_id / ams_id / tray_id in identical shape (verified against models/spoolman_slot_assignment.py, whose ams_id range 0-7 / 128-191 / 255 is fully covered by the existing _global_tray_from_assignment()), so the helper works on either unchanged. The union is strictly safe: it can only add assignments, so it never regresses legacy-mode behavior and never reports a genuinely-unassigned tray as covered. Scope note: this does not add RFID-extra.tag resolution (a tray bound purely via the loaded spool's RFID tag with no slot-assignment row) — that needs the Spoolman client and is a deeper change; the reported false positive is entirely covered by the union since the Assign-Spool UI writes SpoolmanSlotAssignment. Tests: 3 new in test_spool_assignment_notifications.py (the reporter's suggested cases) — Spoolman-only binding suppresses the notification; Spoolman partial coverage flags only the uncovered tray; mixed-mode (A1 legacy + A2 Spoolman) union covers all used trays. The test fake now routes execute() by target table so either mode can be exercised; the existing legacy-mode test still passes unchanged. 4 notification tests green; backend ruff clean. Audit follow-up: a sweep of every SpoolAssignment consumer confirmed the other internal-mode-only users (usage_tracker.py, spool_tag_matcher.py, routes/inventory.py) are correct — internal and Spoolman modes have parallel implementations by design — but surfaced an asymmetry in routes/settings.py: the Spoolman-mode toggle cleared SpoolAssignment when switching on but never cleared SpoolmanSlotAssignment when switching off, so stale Spoolman rows lingered. Harmless before, but now that the notification unions both tables those stale rows would wrongly count as "assigned" in internal mode and suppress a legitimate warning. Added the symmetric clear — switching back to internal mode now deletes SpoolmanSlotAssignment rows, mirroring the existing on-switch behavior. 1 integration test in test_spoolman_slot_assignments.py::TestModeSwitchClearsAssignments covers it; 23 slot-assignment + 45 settings/slot tests green.
  • Local Profiles: the search bar no longer disappears when a query matches nothing (#1470, reported by pwostran) — Typing a query in Settings → Local Profiles that matched no preset made the search bar itself vanish, leaving the user unable to clear or edit the query without a full page refresh. Root cause in frontend/src/components/LocalProfilesView.tsx: the search bar was gated on {totalCount > 0 && …}, and totalCount is the sum of the post-filter filaments / printers / processes lengths — so the moment the query filtered every column to empty, totalCount hit 0 and the search bar unmounted along with the columns. The totalCount === 0 "No local presets yet" empty state then took over, which also misleadingly implied nothing was imported. Fix: added hasAnyPresets, computed from the pre-filter preset counts (presets?.filament/printer/process lengths), and gated the search bar on that instead — it stays mounted as long as any preset exists, regardless of the query. The empty state is now split: !hasAnyPresets shows the genuine "No local presets yet" + import hint, while hasAnyPresets && totalCount === 0 shows a new "No presets match your search" message (with a search icon) so the two cases are no longer conflated. New noSearchResults i18n key added with real translations in all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Tests: 1 new in LocalProfilesView.test.tsx — types a non-matching query and asserts the search bar is still in the DOM, retains the typed value, and the no-matches message renders. 10 LocalProfilesView tests green; i18n parity 4859 keys × 8 locales; frontend build clean.
  • Failure Detection: the Status panel's Low / High thresholds now reflect the selected sensitivity (#1469, reported by JohnMacOB) — Reporter changed the Sensitivity dropdown (Low / Medium / High) in Settings → Failure Detection and the "Low / High thresholds" readout in the Status panel never moved off 0.38 / 0.78, so the setting looked dead. Detection itself was always correct — the classifier at backend/app/services/obico_detection.py:280 uses classify(score, settings["sensitivity"]) with the real value, so warnings/failures triggered at the right confidence for the chosen level. The bug was display-only: ObicoDetectionService.get_status() computed the displayed thresholds with a hardcoded thresholds("medium") (obico_detection.py:324), ignoring the configured sensitivity. thresholds() is BASE × SENSITIVITY_MULT — low ×1.25 → 0.48 / 0.98, medium ×1.0 → 0.38 / 0.78, high ×0.75 → 0.29 / 0.59 — so the panel always showed the medium row whatever the user picked, making a working setting look broken. Fix: get_status() takes an optional sensitivity parameter (default "medium", so thresholds()'s own unknown-value fallback still applies) and the /obico/status route — which already loads settings fresh and has settings["sensitivity"] in hand — passes it through. The readout now updates the instant the dropdown change is saved (the frontend already invalidates the obico-status query on save), with no wait for the next poll cycle. Tests: 1 new in test_obico_detection.py::TestGetStatustest_thresholds_reflect_configured_sensitivity asserts low > medium > high for both threshold bounds and that the default / unknown sensitivity falls back to medium. 47 obico unit tests + 5 obico API integration tests green; backend ruff clean.
  • Printer serial numbers are normalized on input, and a stale connection that never receives a status report now logs an actionable hint (#1465, reported by jmneely94) — Reporter's H2C connected over MQTT+TLS without error but every status field stayed unknown (state, firmware, AMS, wifi); the P1S on the same instance worked. The report concluded the H2C firmware doesn't publish MQTT — but its own evidence disproves that: Bambu Studio LAN mode showed the H2C's live status, and Bambu Studio's device telemetry is MQTT device/<serial>/report (the "HTTPS API, not MQTT" claim in the report is incorrect — only file transfer/FTP and the camera stream are non-MQTT). A printer visible in Studio is publishing. Actual cause is layer-8: connect-OK + subscribe-OK + zero messages forever means Bambuddy subscribed to a topic with no traffic — the MQTT broker is the printer, it authenticates on the access code and SUBACKs a subscription to any topic string, so a wrong or mis-cased serial connects fine and silently receives nothing. The reporter's own mosquitto_sub "verification" subscribed to device/31b8c…/report lowercase; MQTT topics are case-sensitive and Bambu serials are uppercase, so that test reproduced the mistake rather than validating the firmware theory. Bambuddy did nothing to guard against it: schemas/printer.py took serial_number as a bare string, the model stored it verbatim, and bambu_mqtt.py built the topic as f"device/{self.serial_number}/report" with no .upper() / .strip(). Two hardening changes so this class of mistake self-heals or at least diagnoses itself. (1) Serial normalization: a field_validator on PrinterBase.serial_number now .strip().upper()s the value (rejecting blank-after-strip), so a serial pasted in the wrong case or with stray whitespace produces the correctly-cased subscription topic. PrinterUpdate has no serial_number field so the create path is the only entry point; existing DB rows are not migrated (forward fix — a mis-cased existing printer is corrected by re-adding it). (2) Zero-report diagnostic: BambuMQTTClient now counts report-topic messages per connection (_report_messages_since_connect, reset in _on_connect, incremented in _on_message when msg.topic == self.topic_subscribe). When check_staleness() fires its reconnect and that counter is still 0, it logs a one-shot WARNING (_zero_report_hint_logged guards against spamming the 60-90s reconnect loop) telling the user the most common cause is a wrong/mis-cased serial and that the report topic is case-sensitive — turning a silent indefinite reconnect loop into something actionable in the log / support bundle. Known gap left intentionally: a printer that connects but sends literally zero messages (so _last_message_time stays 0) never trips is_stale() at all — that grace behavior is #887-sensitive and out of scope here; the diagnostic covers the observed case where staleness does fire. Tests: 5 in new test_printer_schema.py (uppercase, whitespace-strip, both, already-normalized no-op, blank rejected); 2 in test_bambu_mqtt.py::TestStaleReconnect (hint logs once when no reports received then stays silent on the next stale cycle; no hint when reports were received — a normal mid-session quiet gap). 659 printer/MQTT/scheduler tests green across the affected suites; backend ruff clean.
  • Smart-plug "Auto Off after Drying" no longer kills the printer seconds into a drying cycle (#1462, reported by Kyobinoyo) — Reporter on an X2D (firmware 01.01.00.00) set a 1-hour AMS dry and a short auto-off-after-drying delay, and the printer powered off almost immediately. The support bundle made it unambiguous: every Sent drying command … duration=1 was followed 3-9 seconds later by AMS 0 drying complete (dry_time 60 → 0) — the drying-complete callback fired seconds after drying started, not when it finished, arming smart-plug auto-off against a printer that was still drying (and potentially printing). The reporter's hypothesis was a missing print-state check; the actual cause is a false completion detection. Root cause — partial AMS-update merge drops dry_time: backend/app/services/bambu_mqtt.py merges partial AMS MQTT updates. The no-tray branch correctly preserved top-level fields ({**existing_unit, **ams_unit}), but the tray-bearing branch rebuilt the unit as {**ams_unit, "tray": merged_trays} — spreading only the new partial, never existing_unit. The printer constantly sends tray-bearing partials that carry no drying fields, so on every such update dry_time (and info, which drives dry_status / dry_sub_status) was silently dropped. The drying falling-edge detector then read int(ams_unit.get("dry_time") or 0) → field absent → current = 0; with previous a real countdown value (60, 50, 52 in the reporter's log) the previous > 0 and current == 0 check fired a false "drying complete". Fix, two parts. (1) Tray-bearing merge branch now spreads existing_unit first — {**existing_unit, **ams_unit, "tray": merged_trays} — so dry_time, info, humidity, temp and any other top-level field a partial omits survive the merge, matching the no-tray branch. This also fixes dry_status / dry_sub_status flapping in the UI on every tray update (same dropped-field bug, broader symptom). (2) Defence-in-depth in the falling-edge detector: it now only evaluates the edge when dry_time is explicitly present (ams_unit.get("dry_time") not None) and parseable — an absent or unparseable value is skipped without touching _previous_dry_times, so a missing field can never be read as "drying finished" even if a future merge regression re-introduces a drop. Once detection is correct, on_drying_complete only fires at real completion, so the auto-off timer arms when the user expects. Tests: 1 new in test_bambu_mqtt.py::TestDryingCompleteCallbacktest_tray_only_partial_does_not_fake_completion pushes dry_time=60, then a tray-only partial with no dry_time, asserts no event fired AND state.raw_data["ams"][0]["dry_time"] still equals 60, then a real dry_time=0 push fires the edge exactly once. 108 drying/AMS tests + 35 smart-plug-manager tests green; backend ruff clean.
  • Scheduler: queue items with force_color_match filament overrides now produce a correct AMS mapping at dispatch (#1437, fixed by external PR #1440 from Person2099) — Contributor's own bug report and fix. He had a queue item with filament_overrides: [{slot_id: 1, type: "PLA", color: "#CBC6B8", force_color_match: true}] and ams_mapping: null, expecting Bambuddy to translate the override into a slot mapping at dispatch time. Instead the scheduler dispatched with ams_mapping: null and the P1S fell back to type-only AMS matching, picking the wrong-colour slot. Two-layer root cause he traced end-to-end. (1) backend/app/services/filament_requirements.py:69: extract_filament_requirements(file_path, plate_id=None) fell through to _collect_filaments(root, filaments) whose XPath ./filament only matches direct children of <config>. Modern BambuStudio 3MFs wrap filaments inside <plate> elements, so this XPath returned [] on every modern multi-plate 3MF when no specific plate was targeted — which is the standard scheduler call shape for queue items without a pinned plate. The downstream "no AMS mapping" cascade ALL flowed from this empty filament_reqs result. Fix walks <plate> elements first, dedupes by slot_id (highest used_grams wins on ties — sane because BambuStudio slots are project-wide and the entry that extruded the most is the most representative for AMS planning), and preserves the old ./filament XPath as a fallback when no <plate> elements are present, so legacy 3MFs continue to parse unchanged. (2) backend/app/services/print_scheduler.py:792 — defence in depth: even with (1) in place, edge cases exist where _get_filament_requirements can still return None (3MF missing slice_info.config entirely, IO failure during ZIP extraction, etc). New _build_override_direct_mapping(force_overrides, status) helper kicks in at exactly that moment when force_color_match overrides are present — builds the requirement list directly from the overrides (slot_id, type, color, empty tray_info_idx) and delegates to the existing _match_filaments_to_slots() cascade against the printer's loaded AMS state. Wrong-colour slot credit via the cascade's type-only fallback is impossible-by-construction because the upstream _get_missing_force_color_slots() printer-eligibility gate at :590 already requires an exact (type, normalised colour) pair to be loaded before the printer is even considered for the job, so by the time _build_override_direct_mapping runs the exact match is guaranteed in the loaded set and the cascade's exact_match branch wins (colour normalisation is identical on both sides — tray_color.replace("#", "").lower()[:6]). Pref-only overrides (without force_color_match) intentionally do NOT trigger the fallback — they keep the pre-PR "no mapping, printer picks defaults" behaviour, so the new fallback is strictly opt-in via force_color_match: true. Backwards-compat triple-checked: legacy 3MF format unchanged (preserved fallback path); plate_id != None branch untouched (entire fix is inside the else of if plate_id is not None); filament_overrides=None / [] / no-force-entries all preserve the existing return None path; malformed JSON in filament_overrides is caught by the existing try/except, logged, and still returns None. Tests (22 new across two files; all pass on pytest -n 30): backend/tests/unit/services/test_filament_requirements.py — 4 tests covering the plate_id=None modern-format path, multi-plate collection, slot-dedup-by-highest-grams, and single-plate-modern-format. backend/tests/unit/test_scheduler_force_color_ams_fallback.py — 18 tests across TestBuildOverrideDirectMapping (single override matches AMS slot, empty AMS returns None, no colour match still produces a mapping length, multi-override produces multi-element mapping, external spool match yields global_tray_id 254, tray_info_idx is cleared) and TestComputeAmsMappingFallback (fallback used when reqs empty + force overrides present, fallback NOT used when no force_color flag, fallback NOT used when overrides None, normal path still used when reqs available, printer-status-unavailable returns None gracefully). 5079 backend tests + ruff + frontend build all clean post-merge; #1457/#1459/#1440 verified non-interacting (different services, different code paths, different timings). External-PR-checklist (per [[feedback_pr_changelog_required]]): contributor doesn't add CHANGELOG, this entry added by Martin post-merge.
  • Spoolman: per-print weight reporting now works for tag-less spools assigned via the Bambuddy UI (#1459, reported by Moskito99 — follow-up to #1119) — Reporter on Postgres + Postgres-backed Spoolman noticed that prints finished cleanly but the spool's remaining weight in Spoolman was never decremented. He correctly traced it: Spoolman's extra.tag on his spool was empty, and writing a value in there by hand made weight tracking start working. Root cause is one missing fallback path. After #1119 introduced the local spoolman_slot_assignments table as the authoritative binding for tag-less spools (RFID is the binding for Bambu Lab spools, slot-assignment is the binding for generic / non-RFID spools), the Assign UI deliberately leaves Spoolman's extra.tag field empty for those spools — and after the #1457 cleanup we now actively clear it on re-binding to stop ghost links resurfacing in the hover card. That's the correct write-side behaviour. But the per-print weight tracker (backend/app/services/spoolman_tracking.py:_report_spool_usage_for_slots) only resolved the bound spool via client.find_spool_by_tag(spool_tag) — a single tag-lookup against Spoolman's extra.tag. For tag-less spools that returns None and the tracker silently skipped the slot. The tracker never consulted the local spoolman_slot_assignments table that has the answer (verified: grep -n SpoolmanSlotAssignment backend/app/services/spoolman_tracking.py returned zero hits before this fix). So Bambu Lab RFID users got correct weight reporting (their extra.tag is auto-populated by the AMS-sync create_spool path at backend/app/services/spoolman.py:1076), and generic-spool users on Spoolman saw weight tracking silently no-op — exactly the symptom Moskito99 saw. Fix adds a two-stage resolver inside _report_spool_usage_for_slots: stage 1 is the existing client.find_spool_by_tag(spool_tag) (RFID and any RFID-equivalent extra.tag value), stage 2 is the new _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id) helper that queries the SpoolmanSlotAssignment table for (printer_id, ams_id, tray_id) → spoolman_spool_id. The (ams_id, tray_id) pair is derived from the slot's global_tray_id via the existing _global_tray_id_to_ams_slot helper — same translation used for fallback-tag generation, so external slots (global 254/255 → ams_id=255, tray_id=0/1) and AMS-HT slots (global 128+ → ams_id=global, tray_id=0) all resolve correctly. Stage-1-wins ordering is deliberate: when an RFID-bound spool is in the slot, extra.tag is the authoritative binding, even if the slot-assignment table happens to point at a different spool (legacy state). The resulting [SPOOLMAN] … via tag vs … via slot-assignment suffix in the success log makes it obvious which path resolved each slot, which support bundles will use to confirm the fix is live. printer_id threaded through the three callers (_report_partial_usage G-code path, _report_partial_usage linear path, report_usage) — they all already had printer_id in scope. Crucially, extra.tag is NOT auto-populated by this fix — that would re-introduce exactly the pollution #1457 cleaned up (deterministic fallback tags surviving across spool changes and surfacing stale spools in the hover card). The slot-assignment table is the source of truth for non-RFID bindings; Spoolman's extra.tag is reserved for hardware RFID identifiers. Tests: 5 new in backend/tests/integration/test_spoolman_tracking_slot_fallback.py: the bug repro (tag missing + slot-assignment present → use_spool by the slot-assignment's id); tag-match wins when both present (a regression that flips the resolution order would credit the wrong spool); skip-when-neither (no spool resolution attempted); skip-when-printer_id-not-supplied (legacy call shape stays inert); external-slot translation (global 254 → ams_id=255 tray_id=0 lookup works). New patch_async_session fixture routes the tracker's module-level async_session to the test engine so the in-test SpoolmanSlotAssignment insert is visible to the lookup. Postgres compatibility: verified — the lookup uses a plain select(...where...).scalar_one_or_none(), no SQLite-only syntax. 642 spoolman/tracking tests + 5 new = 647 green; full backend suite 5065 green; ruff clean.
  • Spoolman: AMS hover card and SpoolBuddy fill-bar no longer surface a stale spool after re-assigning a non-RFID slot (#1457, reported by Menthe11) — Reporter on a P1S with generic (non-RFID) PLA saw two different spools rendered in the AMS hover card: the top "Spulen-ID / Im Inventar öffnen" link pointed at an almost-empty black PLA spool that had been in the slot weeks earlier, while the bottom "Zugewiesen" block correctly showed the full spool the user had just assigned via Spoolman. Root cause is two-layered. For non-RFID slots Bambuddy falls back to a deterministic per-slot tag (hash(printer_serial) + ams_id + tray_id, 16 hex chars; see frontend/src/utils/amsHelpers.ts:176). When a user runs Link UI on such a slot, that fallback tag is written to the Spoolman spool's extra.tag — and the existing Link / Assign routes never cleared it from the previous holder when the user re-bound the slot to a different spool. The frontend's hover-card resolver at frontend/src/pages/PrintersPage.tsx:3736 (and the matching sites at :4137 / :4452 for HT and external slots) then preferred that stale tag-link over the user's explicit slot-assignment: linkedSpoolId: (trayTag ? linkedSpools?.[trayTag]?.id : undefined) ?? slotAssignmentForFill?.spoolman_spool_id. So when both layers existed and they disagreed, the stale spool won, and FilamentHoverCard's dedupe at line 377 couldn't collapse the two buttons because the IDs didn't match → two "Im Inventar öffnen" buttons pointing at different spools. The SpoolBuddy AMS page had the identical bug shape in two more spots: getSpoolmanFillForSlot() (the per-slot fill-percentage resolver, line 138) walked tag-link before slot-assignment, so the fill bar reported the old spool's remaining grams instead of the freshly assigned full one; and the slot-action picker's "Linked spool" / "Assigned spool" branches (line 760) showed "Linked spool" whenever a tag-link existed, regardless of whether a (more recent) slot-assignment also existed. Fix has two parts. (1) Frontend precedence swap at all five sites: slot-assignment is the user's most explicit, most recent action — it must outrank the tag-link, which is auto-populated and can be silently stale. With the swap, FilamentHoverCard's existing match-dedupe collapses both buttons into one pointing at the correct spool; SpoolBuddy's fill bar reads from the assigned spool's weight first; and SpoolBuddy's slot-action picker drops the stale "Linked spool" line entirely when a slot-assignment exists. (2) Backend hygiene so the stale state is never written in the first place: a new _clear_stale_tag_links(client, tag, keep_spool_id, log_context) helper in backend/app/api/routes/spoolman_inventory.py enumerates Spoolman spools and PATCHes extra.tag to JSON-empty ('""', the same wire shape unlink_spool already uses so the read-side .strip('"') filter in get_linked_spools skips it) on any spool other than the one being bound that still claims the same tag. Wired into POST /spoolman/inventory/slot-assignments (computes the slot's deterministic fallback tag via the existing get_fallback_spool_tag_for_slot helper in spoolman_tracking.py — newly promoted to a public symbol that mirrors the frontend's getFallbackSpoolTag(serial, amsId, trayId) signature) and POST /spoolman/spools/{id}/link (passes the literal spool_tag being bound — works for both RFID tags and fallback tags). Both are best-effort: per-spool patch failures and Spoolman enumeration failures are logged and skipped, never raised, so the assign/link path never wedges on a Spoolman hiccup. Existing assign-route tests stay green because their fixtures' Spoolman client mock already had get_spools returning [] (or now does — fixture updated in test_spoolman_slot_assignments.py, test_spoolman_slot_concurrency.py, test_spoolman_slot_assignment_mqtt.py, and the link-route test fixture in test_spoolman_api.py). Tests (8 new in backend/tests/unit/test_spoolman_stale_tag_cleanup.py): clears one other-spool while keeping the bound spool and unrelated-tag spool intact; case-insensitive match (the helper uppercases both sides because get_linked_spools already does); empty-tag short-circuits without enumerating spools; keep_spool_id guards against clearing the spool being bound; Spoolman 5xx during enumeration is swallowed and the call returns 0; one per-spool patch failure doesn't abort the rest of the cleanup; the slot-fallback wrapper computes the right tag and clears it; empty serial returns 0 without enumerating. Backend: ruff clean, 581 spoolman tests + 8 new = 589 green. Frontend build clean.
  • AMS drying popover no longer renders off the bottom of the viewport + diagnostic logging for the silent-drying-ignore bug (#1447, reported by kleinweby) — Two distinct bugs in the same report, both shipped in this PR. (1) Popover positioning: reporter on P1S + AMS-HT couldn't see the Start button on the drying popover and worked around it via DevTools to confirm the popover was actually there, just clipped below the fold. Root cause in frontend/src/pages/PrintersPage.tsx:3498 / :4011 (two identical sites — one for the compact AMS row, one for the dual-nozzle layout): the flame-icon onClick computed popover position as a fixed { top: rect.bottom + 4, left: Math.max(8, rect.right - 240) } with no viewport-overflow check. The flame icon sits at the bottom of the AMS info section on the printer card, so on most realistic viewports rect.bottom + 4 + popover_height(~320px) > viewport.height and the popover rendered partially or entirely off-screen. Fix extracts a computePopoverPosition() helper in frontend/src/utils/popoverPosition.ts that defaults to placing the popover below + right-aligned to the trigger (preserving the original visual layout), flips ABOVE the trigger when below would overflow AND above would fit, stays below in the degraded case where neither fits (popover taller than viewport — at least the top is visible and the user can scroll inside), and clamps the left coordinate so a trigger near either viewport edge can't push the popover off-screen horizontally either. Both PrintersPage callsites now go through the helper. (2) Diagnostic logging for the silent-drying-ignore: reporter's support bundle showed the printer receives every ams_filament_drying command (multiple start / stop attempts on ams_id=128, P1S 01.10.00.00 firmware), the printer ACKs each one, but the AMS info field never changes — drying neither starts nor stops on Bambuddy's request, while pressing Start on the printer's touchscreen worked immediately (so the hardware path is healthy and the LAN MQTT channel is delivering). The Bambuddy command JSON matches the format documented as working on H2D, all required fields are present, types match BambuStudio. Diagnosing the silent rejection needs the printer's actual response payload — whether result: "fail" and the specific reason code — but bambu_mqtt.py:918 was only logging the response command name, not the body. The existing extrusion_cali_* / ams_filament_setting debug path at :919-920 was the template; this PR extends it to ams_filament_drying at INFO level specifically (not DEBUG like its siblings) because drying responses are rare — user-initiated only — and INFO ensures the body lands in support bundles by default without needing the user to bump log level first. Paired with a matching outgoing-side INFO log inside send_drying_command that captures the full wire JSON, so the next support bundle has both halves of the conversation. The actual command-side fix can't happen without that data (no guessing — flipping close_power_conflict: true or otherwise mutating a field that matches the documented-working H2D shape could break currently-working installs). When kleinweby retries on this build and re-attaches a bundle, the rejection reason is visible and the command-side fix follows from real data. Tests (8 new in __tests__/utils/popoverPosition.test.ts): below-has-room places below; right-align to trigger; below overflows flips above; degraded case stays below; clamps right-edge and left-edge triggers; respects custom margin and gap. 276 backend service tests + frontend build clean.
  • Stats: Print Activity heatmap buckets prints by local date, not UTC date (#1446, reported and root-caused by needo37) — Reporter on CDT (UTC-5) noticed that prints finished in the local evening were jumping to "tomorrow's" cell on the GitHub-style contribution heatmap on the Stats page. He went through frontend/src/components/PrintCalendar.tsx and identified the root cause: line 30 split the raw ISO string on 'T' to get a YYYY-MM-DD key, which always returns the UTC date — but the cell tooltip (line 161) rendered via toLocaleDateString(), which is local-tz aware. Same data, two renderers, only one was tz-correct. He confirmed with DB query: rows 29 and 30 stored as 2026-05-18 ... UTC were both local May 17 (20:46 CDT and 22:39 CDT), and the Archives → Print Log view formatted them correctly as May 17 via toLocaleString() while the heatmap split them onto May 18 via the raw-ISO shortcut. The component had two more instances of the same shape that I caught while applying the fix: line 152 built the per-cell lookup key via day.toISOString().split('T')[0] (the day Date objects produced by the calendar-generation loop are local-tz constructed via new Date() + setDate, so toISOString() shifted them back to UTC before the lookup — would have re-broken the join even after the bucketing fix), and line 154's "today" highlight comparison used new Date().toISOString().split('T')[0] too (so at e.g. 23:00 CDT the heatmap would have ringed UTC-tomorrow's cell instead of local-today's). Fix adds a localDateKey(input: string | Date): string helper in frontend/src/utils/date.ts that wraps parseUTCDate() and formats via the local-tz getters (getFullYear / getMonth / getDate with two-digit padding), returning a stable comparable YYYY-MM-DD string. PrintCalendar.tsx uses it in all three spots — bucket key for input ISO strings, grid-cell lookup key, and "today" highlight — so the bucketing, the cell join, and the today ring all live on the same local-tz axis as the user's tooltip label. Backend stays UTC (PrintLogEntry.created_at unchanged); bucketing is a presentation concern and the browser already knows the user's tz. The reporter's broader point ("same fix needed anywhere else the frontend buckets timestamps to days") still has stragglers — StatsPage.tsx:55-84 (computeDateRange) builds the dateFrom/dateTo strings for backend stats queries using getUTC* getters everywhere, so a "this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based ranges to the backend; that's a separate, deeper bug because it also requires the backend to filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting model today. Punted with a localDateKey helper available for reuse when that work lands. Tests (5 new in __tests__/utils/date.test.ts): keys a local-evening Date to its local date (the bug repro), reproduces the reporter's row-30 case (a moment whose UTC date is "tomorrow" keys to local "today"), pads single-digit month/day, handles null / undefined / empty defensively, and accepts both Date and ISO-string inputs end-to-end via parseUTCDate. 74 date-util tests green; frontend build clean. Tests are written tz-independently — they construct new Date(2026, 4, 17, 22, 0, 0) via the local-time constructor form so they assert correctly regardless of which tz the CI runner happens to be in.
  • Printers: Add Printer no longer hangs the container on P1S (#1445, reported by psybernoid and confirmed by thomassjogren) — Regression introduced in 0.2.4.2 by the fix(printers): refuse to add a printer when the MQTT probe fails change (b51598e). That commit added a pre-insert MQTT probe to POST /printers/ via printer_manager.test_connection() to catch mistyped access codes before persisting an empty card — but the probe had two compounding bugs that bit P1S specifically. First, a fixed await asyncio.sleep(2) checked state.connected exactly once at t=2s: P1S firmware's broker / TLS handshake routinely needs 3–5s to surface a CONNACK on a cold MQTT session (same firmware family that already has the documented "broker stops publishing but TCP stays alive" quirk at bambu_mqtt.py:3181), so the probe falsely rejected a printer that would have connected fine. Second, the finally: client.disconnect() call ran synchronously on the asyncio thread — BambuMQTTClient.disconnect() ends in paho's loop_stop() which join()s the network thread, and if that thread was still mid-TLS-handshake to the slow P1S socket when teardown ran, the join() blocked the asyncio thread for as long as the handshake took to either complete or fail. POST /printers/ therefore wedged, all other HTTP requests queued behind it, and Docker healthcheck timed out → user-visible symptom: "the container hangs." Reporter's workaround (downgrade to 0.2.4.1, add P1S, upgrade back) worked because 0.2.4.1's create-printer route skipped the probe entirely, so the row persisted immediately and the slow handshake happened on a fire-and-forget connect_printer() in the background. Fix swaps the fixed-sleep + sync-disconnect pair for a polling loop with an 8s budget (PROBE_TIMEOUT_SECONDS, configurable as class attributes for tests) that early-returns the moment state.connected flips True — so happy-path connects still finish in ~1–2s and slow brokers get the headroom they need — and moves client.disconnect() to await asyncio.to_thread(client.disconnect) so paho's thread-join can never block the event loop. The new connect_printer from-existing-row flow that runs after a successful probe is unchanged (still fire-and-forget). The empty-card-report-prevention goal of the original probe stays intact: a genuinely wrong access code still results in connected=False after 8s of polling, the 400 with code=printer_connection_failed still fires, the row is still never persisted. Tests (2 new in test_printer_manager.py): test_test_connection_polls_and_returns_early_on_connect simulates the P1S timing — connected=False at probe start, flips True ~500ms in — and asserts the probe early-returns in under 1.5s with success=True (a regression that reverts to the fixed sleep fails this immediately); test_test_connection_disconnect_runs_off_loop mocks a deliberately-slow blocking disconnect (mirrors paho's loop_stop() join semantics) and asserts (a) disconnect ran on a thread other than the asyncio thread, and (b) a concurrent heartbeat coroutine kept ticking while disconnect was blocking the worker thread, proving the event loop wasn't stalled. The existing test_test_connection_failure test was patched to override PROBE_TIMEOUT_SECONDS to 0.4s so the negative path still runs fast under CI. 6 printer-create integration tests still green; ruff clean.
  • Stats: Failure Analysis widget no longer shows "Unknown" for archives classified after the fact (#1444, reported and root-caused by needo37) — Reporter spotted that the Stats page "Top Failure Reasons" widget grouped failed prints as Unknown even after they'd been classified via the Edit Archive modal. He went through the data layer and identified the desync: two failure_reason columns exist — print_archives.failure_reason written by PATCH /archives/{id} and print_log_entries.failure_reason read by the widget (backend/app/services/failure_analysis.py:88). PrintLogEntry.failure_reason gets captured exactly once at print-completion time (backend/app/main.py:3641) by copying archive.failure_reason — and at that moment the archive value is still NULL because the user hasn't picked a reason yet. The Edit Archive modal's PATCH route then writes only to print_archives via a generic setattr loop, never touching the log entry → widget stays stuck on Unknown forever. The reporter confirmed the desync at the DB level (archive.failure_reason = 'Adhesion failure', print_log_entry.failure_reason = NULL). Fix mirrors failure_reason and status from the PATCH payload to the most recent PrintLogEntry for that archive (highest id). Latest-only because archive.failure_reason / status already reflect the latest run's outcome (each reprint clears the archive's reason at main.py:2195 and rewrites it at completion), so the Edit Archive modal is implicitly showing — and editing — the latest run; reprints of an archive that succeeded on the second attempt keep the original failed run's classification intact. Scoped to those two fields only — cost, print_name, printer_id etc are deliberately not mirrored because per-run values legitimately diverge from archive-level ones (e.g. partial-print cost on a failed run differs from the source archive's full-print cost, see _compute_run_filament_grams at main.py:596). Tests (3 new in test_archives_api.py): the bug repro (failure_reason mirrors), the status case (the second field the reporter flagged), and the reprint guard (only the latest of multiple entries gets touched, an earlier entry keeps its prior reason). 55 archives-API tests green; ruff clean.
  • SpoolBuddy: spool ID surfaced everywhere a spool's identity is rendered + Write-Tag page honours Spoolman mode (#1439, reported + partially prototyped by flom89) — Reporter buys filament in bulk and registers every individual spool in Spoolman at intake time (each gets a unique ID + a printed barcode that goes onto the physical roll when it's unboxed). When linking an NFC tag to one of those rolls in SpoolBuddy, the picker showed only material + colour + brand — so for ten identical "Black PLA" rolls every row looked the same. The user had no way to tell which physical spool they were about to bind the tag to; the original #1385 fix had surfaced the ID in Bambuddy's main UI (SpoolFormModal, FilamentHoverCard, the inventory-mode LinkSpoolModal) but the parallel SpoolBuddy components had been missed — they ship as part of the Bambuddy frontend repo under frontend/src/components/spoolbuddy/ and frontend/src/pages/spoolbuddy/, not as a separate codebase. Part 1 — ID surface, seven spots: #<id> in muted small monospace added to LinkSpoolModal.tsx (the link-tag-to-spool picker — the reporter's primary use case), SpoolBuddyWriteTagPage.tsx (write-tag picker — reporter's second screenshot), AssignToAmsModal.tsx header (single-spool context but disambiguating IDs help confirm the right roll was picked), TagDetectedModal.tsx (defined but unmounted today — kept consistent for future use), SpoolInfoCard.tsx (the found-tag panel on the right side of the dashboard — the "main screen" view), InventorySpoolInfoCard.tsx (matching inventory variant), and SpoolBuddyAmsPage.tsx AMS-slot assigned-spool block. All seven placements mirror the #1385 pattern (#<id> with shrink-0 so truncation never hides the ID). Frontend-only — the ID was already on the Spool / InventorySpool API shape (spool.id); these seven files just weren't surfacing it. Part 2 — Spoolman-mode parity on the Write-Tag page: reporter then surfaced that the same page hardcoded api.getSpools(false) regardless of inventory backend — so users in Spoolman mode (whose authoritative inventory is at Spoolman, not the internal table) saw spools they never created, and a successful tag write would bind the NFC tag to the wrong backend (the backend /spoolbuddy/nfc/write-tag route is mode-aware via _get_spoolman_client_or_none, but the frontend was driving it with internal-mode IDs that don't exist on the Spoolman side). Fix follows the wrapper pattern InventoryPage uses (InventoryPageRouter at :445): page detects spoolmanMode from a getSpoolmanSettings query at the top and threads it through, with enabled: spoolmanModeReady gating the spool fetch until settings load so we don't burn a wrong-backend request during the initial render. Every API call in the page now branches on spoolmanMode — 6 sites: the main spool list, the NewSpoolTouchForm's autocomplete spool list, the untag flow (linkTagToSpool vs linkTagToSpoolmanSpool — the Spoolman variant doesn't accept data_origin since Spoolman manages that), the K-profile save (saveSpoolKProfiles vs saveSpoolmanKProfiles), single-spool create (createSpool vs createSpoolmanInventorySpool), and bulk create (bulkCreateSpools vs bulkCreateSpoolmanInventorySpools — the Spoolman variant returns a SpoolmanBulkCreateResult envelope vs raw array, handled with a duck-typed 'created' in result check that mirrors SpoolFormModal's existing pattern). Same shape rule as [[feedback_sqlite_and_postgres_upfront]] / [[feedback_inventory_modes_parity]]: both modes ship in the same drop, no spoolmanMode ? undefined : ... UI gates. Tests: 3 new in SpoolBuddyWriteTagPage.test.tsx — the ID-visibility regression with two identical PLA-Red rolls IDs 42 / 43 (a future refactor that drops the ID span breaks it), plus two parity regressions (reads from internal inventory when Spoolman mode is OFF and reads from Spoolman when Spoolman mode is ON — the latter asserts getSpools is NOT called when the user is in Spoolman mode, so re-hardcoding the internal endpoint breaks CI immediately). 11 WriteTagPage tests + 51 other SpoolBuddy component tests green (62 total); frontend build clean.
  • FTP: P2S upload truncates / 426 "Failure reading network stream" on Python 3.13 (#1401, reported and root-caused by iitazz) — Reporter on a P2S running firmware 01.02.00.00 saw every Bambuddy-initiated print fail with the printer's on-screen "unable to parse 3mf file" error ~30 s in; downloading the file back off the printer's SD card confirmed it was truncated at exactly 7 × 64 KB (clean chunk-boundary cut). Initial #1417 follow-up tightened our 426 handling so we'd surface upload failures instead of silently dispatching a print of a partial 3MF — but that only stopped Bambuddy from hiding the problem; the actual upload still failed. The reporter then dug into it with Gemini and identified the real cause: Python 3.13's default ssl.create_default_context() negotiates TLS 1.3 when both peers support it, but the printer's vsFTPd build implements session reuse on the FTPS data channel against an old OpenSSL that doesn't tolerate TLS 1.3's asynchronous session-ticket model. The control-channel handshake completes, the data channel tries to resume the session, the resumption races, the data channel gets torn down mid-stream — first ~448 KB of bytes already in the TCP buffer land on the SD card, the rest never make it, printer's vsFTPd replies 426 instead of 226. Fix caps the SSL context's maximum_version to TLS 1.2 so session resumption is synchronous and the upload completes normally. Implementation follows the pattern just established by camera_profiles.py in the #1395 follow-up: a new backend/app/services/ftp_profiles.py module with an FTPProfile frozen dataclass (one field today, cap_tls_v1_2: bool = False) and a per-model registry. Default profile keeps the historical TLS-1.3 negotiation; P2S (display name + internal SSDP code N7) overrides with cap_tls_v1_2=True. ImplicitFTP_TLS.__init__ gains a matching cap_tls_v1_2 kwarg; BambuFTPClient.connect() looks up the profile and threads the flag through. Deliberately scoped to P2S only — X1C / P1S / H2D installs that work today stay on the negotiated TLS 1.3; flipping a future model to the capped path is a one-line entry in _PROFILES when a new reporter surfaces the same symptom. Considered but rejected the reporter's second proposed change (revert manual transfercmd + sendall back to storbinary) — the stated rationale ("raw sendall breaks OpenSSL 3.x framing") is incorrect (CPython's storbinary itself uses sendall internally; the actual socket-level behaviour is identical), the move to manual transfercmd was deliberate to dodge A1 hanging in storbinary's synchronous voidresp(), and the #1417 SIZE-check escape for the "data is intact on the SD card despite the 426" race lives in the manual-transfer path — a switch to storbinary would lose that protection. Tests: 9 new in test_ftp_profiles.py (default profile doesn't cap; unknown / empty model falls back; P2S display name and N7 SSDP code both resolve to capped; lookup is case-insensitive; X1C / H2D / P1S / A1 stay uncapped; dataclass is frozen; integration test pins the wiringImplicitFTP_TLS(cap_tls_v1_2=True) actually sets ssl_context.maximum_version == TLSVersion.TLSv1_2, guards against a future refactor that drops the profile→context wiring while keeping the registry looking correct). 87 existing test_bambu_ftp.py tests still green; ruff clean.
  • Library 3D preview: complex multi-part 3MFs no longer freeze the page (#1412, reported by anthonyma94) — Reporter opened the 3D preview on a multi-color parted MakerWorld statue ("Mecha Mewtwo No AMS Multi Color Parted Statue") and the whole Bambuddy UI locked up — modal close button unresponsive, had to kill the tab. Root cause was in frontend/src/components/ModelViewer.tsx: the 3MF parse runs entirely on the browser main thread (JSZip extract + DOMParser + getElementsByTagName('vertex') / ('triangle') iteration + mergeGeometries), with no yield points between iterations. Bambu Studio's external-component shape (<component p:path="..."/> per part) compounds this — each component triggers another async file extract + DOM parse + vertex/triangle loop, all chained without surrendering control to the event loop between phases. For trivial models (the towel hook and Bambu scraper the reporter cited as working) the total wall-clock is short enough that the freeze isn't visible; for parted statues with dozens of components and high-poly meshes, the main thread is pegged for tens of seconds → browser shows "page unresponsive" and the close button can't fire. Stopgap that shipped here adds explicit nextTick() yields (await new Promise(r => setTimeout(r, 0))) at four hot spots: every 20 000 vertex iterations, every 20 000 triangle iterations, once per top-level <object> iteration, and once per <component> iteration. Parse wall-clock is unchanged — these yields don't make parsing faster, they just surrender the main thread back to the browser between batches so the modal can be closed, the page can scroll, and the loading spinner can actually render. Constants live next to the helper at the top of the file with a comment justifying the picked period (~5–10 ms of work per batch — fine-grained enough to keep frames flowing, coarse enough not to drown the loop in setTimeout dispatch overhead). The proper fix for this — moving 3MF parse + geometry build into a Web Worker so the main thread is never touched at all — is a tracked follow-up; this stopgap unblocks Anthony's reproduction case today without the worker refactor risk. The earlier close as invalid was a misdiagnosis (initial reading was that 3D preview only works on sliced files, which the reporter correctly disproved with a separate MakerWorld URL); reopened, fixed, lesson noted. Tests: 21 existing ModelViewerModal.test.tsx tests stay green — the yields are in parseMeshFromDoc and parse3MF which the tests mock around, and the nextTick helper has no observable side effects beyond timing. Frontend build clean.
  • Archives: timelapse auto-attach now works for VP-queue / dispatch prints (#1403 follow-up, reported by pwostran) — Bambuddy uses a snapshot-diff strategy to pick the right MP4 off the printer's SD card after a print (Bambu printers in LAN-only mode don't sync NTP, so file mtimes are unreliable — _scan_for_timelapse_with_retries snapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom of on_print_start's new-archive branch only — the expected-archive branch (which queue / VP-dispatched / reprinted jobs take, anything registered via register_expected_print) exited at its own return without ever snapshotting. So queue prints had _timelapse_baselines[printer_id] unset; the completion-time scan fell into its "take baseline now" fallback that snapshots the SD card after the new MP4 has already landed → the new file sits in the "baseline" set → no diff ever matches → auto-attach silently does nothing. The reporter's bambuddy-support-20260518-185935.zip shows the failure verbatim: Using expected archive 3 for print (skipping duplicate) at 18:41:10, Timelapse was active during print, scheduling auto-scan for archive 3 at 18:58:55, and [TIMELAPSE] Archive 3 has no printer, aborting (a separate bug already fixed by the printer_id-assignment commit in this same train) — and grep -i baseline across both his bundles returns zero hits, confirming the snapshot never ran. Fix extracts the inline baseline-capture into _capture_timelapse_baseline_at_start(printer, printer_id, logger) and calls it from BOTH branches of on_print_start (mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch's return). Helper is best-effort with a try / except Exception wrapping _list_timelapse_videos, so a transient FTP failure at print-start logs [TIMELAPSE] Failed to capture baseline at print start: … and the print proceeds — the completion-time fallback still kicks in (with its known limitation), behaviour matching what the new-archive branch had all along. Tests: new test_expected_archive_path_captures_timelapse_baseline in the existing test_print_start_assigns_printer_id_to_vp_archive.py patches _list_timelapse_videos to return two pre-existing videos, runs on_print_start through the expected-archive branch, and asserts _timelapse_baselines[1] == {"earlier_print_a.mp4", "earlier_print_b.mp4"} — a future refactor that removes the call from one of the branches now fails CI. The existing 2 regressions (test_expected_archive_path_assigns_printer_id_when_unset, test_expected_archive_path_preserves_existing_printer_id) plus 50 adjacent expected-archive / layer-timelapse / archive-filtering tests still green. The fixture clearing _expected_prints etc also clears _timelapse_baselines now so test isolation holds.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.