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_printerslist for imported (local) presets, and falls back to theBBL <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'sproject_settings.configwhen those presets are available, instead of always taking the first listed preset. Newfrontend/src/utils/slicerPrinterMatch.ts(11 unit tests) andextract_embedded_presets_from_3mf(5 unit tests);UnifiedPresetnow carriescompatible_printers, exposed for the local tier (backend/app/api/routes/slicer_presets.py); the plates endpoints returnembedded_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.tstranslates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered infrontend/src/i18n/index.tsand selectable as "Español" in the language picker. The parity checker auto-discovers the file —frontend/scripts/check-i18n-parity.mjsgained anES_COGNATESallow-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$'tofrontend/src/utils/currency.tsnext to MXN (Americas dollar-prefix grouping);getCurrencySymbol('BZD')returns'BZ$'and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added infrontend/src/__tests__/utils/currency.test.tscovering 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 viaGET /printers/{id}/diagnostic(saved printer) andPOST /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 GitHubconfig.ymltroubleshooting 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
aggregateGroupSpoolhelper infrontend/src/utils/inventoryGrouping.tswith 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-boundTrent900.3mfsliced via the X1C bundle (POST /slicewithbundle=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_modeloverridden toBambu Lab X1 Carbon,printable_areato 256×256 (X1C bed, not H2D's 350×320),printable_height250 (vs 325),bed_exclude_areapopulated with X1C's 18×28 corner zone,nozzle_diametersingle 0.4 (vs H2D's dual0.4,0.4), and the full X1Cmachine_start_gcodesequence 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!printerMismatchfrom the SliceModalisReadypredicate 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, thesource_printer_modelfield on the/library/files/{id}/platesand/archives/{id}/platesresponses had zero consumers; theextract_source_printer_model_from_3mfhelper inthreemf_tools.py(which opened the 3MF zip and readMetadata/project_settings.configon every plate request) had zero callers. Removed both response keys, both backend extractions, boththreemf_toolsimports, the helper itself, its 6 unit tests, thesource_printer_modelfield fromfrontend/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 EnglishdefaultValue: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. Theslice.printerMismatchkey 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.15to clear CVE-2026-45409 (ReDoS inidna.encode()with crafted Unicode payloads, e.g."٠" * Nor"・" * N + "漢") — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicitidna>=3.15floor inrequirements.txtbetween Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified viapip-auditclean 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:184auto-generates secrets viasecrets.token_urlsafe(64)(~86 chars of entropy, far above any sane minimum) and the file-loaded path at:177rejects secrets shorter than 32 chars. Added a permanent--ignore-vuln CVE-2025-45768to.github/workflows/security.ymlwith 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-4539for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified:pip-audit --ignore-vuln CVE-2025-45768alone 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.tagfield; 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-emptyextra.tag. That was a stale proxy.extra.tagis 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: thespoolman_slot_assignmentstable, whichmodels/spoolman_slot_assignment.pyitself documents as "the source of truth for Spoolman slot assignments". Fix:get_unlinked_spoolsnow decides assignability from that ledger — a spool is assignable iff its id is not inspoolman_slot_assignments— and ignoresextra.tagentirely. Verified the ledger is complete: bothlink_spool(manual link) and the AMS auto-sync (spoolman.pyslot-change persistence) upsert a row for every occupied slot, so nothing genuinely assigned can leak back into the picker.get_linked_spoolsandfind_spool_by_tagkeep usingextra.tagunchanged — 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 Spoolmanextracollision, 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.py—test_get_unlinked_spools_successnow asserts a spool with a non-empty OpenSpoolman-styleextra.tagbut no slot row still appears in the picker; newtest_get_unlinked_spools_excludes_slot_assignedseeds aSpoolmanSlotAssignmentrow 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_assignmentnotification 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.pyqueried only the legacySpoolAssignmenttable, neverSpoolmanSlotAssignment. In Spoolman mode the legacy table is empty (bindings live inspoolman_slot_assignments, the source-of-truth since #1119), soassigned_global_trayscame back empty and every used tray was reported missing. Same class of miss as #1459 (the weight tracker also skippedSpoolmanSlotAssignment) 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 —SpoolAssignmentandSpoolmanSlotAssignmentrows for the printer. Both exposeprinter_id/ams_id/tray_idin identical shape (verified againstmodels/spoolman_slot_assignment.py, whoseams_idrange 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.tagresolution (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 writesSpoolmanSlotAssignment. Tests: 3 new intest_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 routesexecute()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 everySpoolAssignmentconsumer 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 inroutes/settings.py: the Spoolman-mode toggle clearedSpoolAssignmentwhen switching on but never clearedSpoolmanSlotAssignmentwhen 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 deletesSpoolmanSlotAssignmentrows, mirroring the existing on-switch behavior. 1 integration test intest_spoolman_slot_assignments.py::TestModeSwitchClearsAssignmentscovers 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 && …}, andtotalCountis the sum of the post-filterfilaments/printers/processeslengths — so the moment the query filtered every column to empty,totalCounthit 0 and the search bar unmounted along with the columns. ThetotalCount === 0"No local presets yet" empty state then took over, which also misleadingly implied nothing was imported. Fix: addedhasAnyPresets, computed from the pre-filter preset counts (presets?.filament/printer/processlengths), 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:!hasAnyPresetsshows the genuine "No local presets yet" + import hint, whilehasAnyPresets && totalCount === 0shows a new "No presets match your search" message (with a search icon) so the two cases are no longer conflated. NewnoSearchResultsi18n key added with real translations in all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Tests: 1 new inLocalProfilesView.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 atbackend/app/services/obico_detection.py:280usesclassify(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 hardcodedthresholds("medium")(obico_detection.py:324), ignoring the configured sensitivity.thresholds()isBASE × 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 optionalsensitivityparameter (default"medium", sothresholds()'s own unknown-value fallback still applies) and the/obico/statusroute — which already loads settings fresh and hassettings["sensitivity"]in hand — passes it through. The readout now updates the instant the dropdown change is saved (the frontend already invalidates theobico-statusquery on save), with no wait for the next poll cycle. Tests: 1 new intest_obico_detection.py::TestGetStatus—test_thresholds_reflect_configured_sensitivityasserts 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 MQTTdevice/<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 ownmosquitto_sub"verification" subscribed todevice/31b8c…/reportlowercase; 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.pytookserial_numberas a bare string, the model stored it verbatim, andbambu_mqtt.pybuilt the topic asf"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: afield_validatoronPrinterBase.serial_numbernow.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.PrinterUpdatehas noserial_numberfield 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:BambuMQTTClientnow counts report-topic messages per connection (_report_messages_since_connect, reset in_on_connect, incremented in_on_messagewhenmsg.topic == self.topic_subscribe). Whencheck_staleness()fires its reconnect and that counter is still 0, it logs a one-shot WARNING (_zero_report_hint_loggedguards 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_timestays 0) never tripsis_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 newtest_printer_schema.py(uppercase, whitespace-strip, both, already-normalized no-op, blank rejected); 2 intest_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=1was followed 3-9 seconds later byAMS 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 dropsdry_time:backend/app/services/bambu_mqtt.pymerges 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, neverexisting_unit. The printer constantly sends tray-bearing partials that carry no drying fields, so on every such updatedry_time(andinfo, which drivesdry_status/dry_sub_status) was silently dropped. The drying falling-edge detector then readint(ams_unit.get("dry_time") or 0)→ field absent →current = 0; withpreviousa real countdown value (60, 50, 52 in the reporter's log) theprevious > 0 and current == 0check fired a false "drying complete". Fix, two parts. (1) Tray-bearing merge branch now spreadsexisting_unitfirst —{**existing_unit, **ams_unit, "tray": merged_trays}— sodry_time,info, humidity, temp and any other top-level field a partial omits survive the merge, matching the no-tray branch. This also fixesdry_status/dry_sub_statusflapping 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 whendry_timeis 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_completeonly fires at real completion, so the auto-off timer arms when the user expects. Tests: 1 new intest_bambu_mqtt.py::TestDryingCompleteCallback—test_tray_only_partial_does_not_fake_completionpushesdry_time=60, then a tray-only partial with nodry_time, asserts no event fired ANDstate.raw_data["ams"][0]["dry_time"]still equals 60, then a realdry_time=0push fires the edge exactly once. 108 drying/AMS tests + 35 smart-plug-manager tests green; backend ruff clean. - Scheduler: queue items with
force_color_matchfilament 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 withfilament_overrides: [{slot_id: 1, type: "PLA", color: "#CBC6B8", force_color_match: true}]andams_mapping: null, expecting Bambuddy to translate the override into a slot mapping at dispatch time. Instead the scheduler dispatched withams_mapping: nulland 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./filamentonly 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 byslot_id(highestused_gramswins 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./filamentXPath 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_requirementscan still return None (3MF missingslice_info.configentirely, IO failure during ZIP extraction, etc). New_build_override_direct_mapping(force_overrides, status)helper kicks in at exactly that moment whenforce_color_matchoverrides are present — builds the requirement list directly from the overrides (slot_id,type,color, emptytray_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:590already 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_mappingruns the exact match is guaranteed in the loaded set and the cascade'sexact_matchbranch wins (colour normalisation is identical on both sides —tray_color.replace("#", "").lower()[:6]). Pref-only overrides (withoutforce_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 viaforce_color_match: true. Backwards-compat triple-checked: legacy 3MF format unchanged (preserved fallback path);plate_id != Nonebranch untouched (entire fix is inside theelseofif plate_id is not None);filament_overrides=None/[]/ no-force-entries all preserve the existingreturn Nonepath; malformed JSON infilament_overridesis caught by the existing try/except, logged, and still returns None. Tests (22 new across two files; all pass onpytest -n 30):backend/tests/unit/services/test_filament_requirements.py— 4 tests covering theplate_id=Nonemodern-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 acrossTestBuildOverrideDirectMapping(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_idxis cleared) andTestComputeAmsMappingFallback(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.tagon 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 localspoolman_slot_assignmentstable 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'sextra.tagfield 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 viaclient.find_spool_by_tag(spool_tag)— a single tag-lookup against Spoolman'sextra.tag. For tag-less spools that returns None and the tracker silently skipped the slot. The tracker never consulted the localspoolman_slot_assignmentstable that has the answer (verified:grep -n SpoolmanSlotAssignment backend/app/services/spoolman_tracking.pyreturned zero hits before this fix). So Bambu Lab RFID users got correct weight reporting (theirextra.tagis auto-populated by the AMS-synccreate_spoolpath atbackend/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 existingclient.find_spool_by_tag(spool_tag)(RFID and any RFID-equivalentextra.tagvalue), stage 2 is the new_resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)helper that queries theSpoolmanSlotAssignmenttable 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_slothelper — 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.tagis the authoritative binding, even if the slot-assignment table happens to point at a different spool (legacy state). The resulting[SPOOLMAN] … via tagvs… via slot-assignmentsuffix in the success log makes it obvious which path resolved each slot, which support bundles will use to confirm the fix is live.printer_idthreaded through the three callers (_report_partial_usageG-code path,_report_partial_usagelinear path,report_usage) — they all already hadprinter_idin scope. Crucially,extra.tagis 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'sextra.tagis reserved for hardware RFID identifiers. Tests: 5 new inbackend/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). Newpatch_async_sessionfixture routes the tracker's module-levelasync_sessionto the test engine so the in-testSpoolmanSlotAssignmentinsert is visible to the lookup. Postgres compatibility: verified — the lookup uses a plainselect(...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; seefrontend/src/utils/amsHelpers.ts:176). When a user runs Link UI on such a slot, that fallback tag is written to the Spoolman spool'sextra.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 atfrontend/src/pages/PrintersPage.tsx:3736(and the matching sites at:4137/:4452for 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 inbackend/app/api/routes/spoolman_inventory.pyenumerates Spoolman spools and PATCHesextra.tagto JSON-empty ('""', the same wire shapeunlink_spoolalready uses so the read-side.strip('"')filter inget_linked_spoolsskips it) on any spool other than the one being bound that still claims the same tag. Wired intoPOST /spoolman/inventory/slot-assignments(computes the slot's deterministic fallback tag via the existingget_fallback_spool_tag_for_slothelper inspoolman_tracking.py— newly promoted to a public symbol that mirrors the frontend'sgetFallbackSpoolTag(serial, amsId, trayId)signature) andPOST /spoolman/spools/{id}/link(passes the literalspool_tagbeing 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 hadget_spoolsreturning[](or now does — fixture updated intest_spoolman_slot_assignments.py,test_spoolman_slot_concurrency.py,test_spoolman_slot_assignment_mqtt.py, and the link-route test fixture intest_spoolman_api.py). Tests (8 new inbackend/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 becauseget_linked_spoolsalready does); empty-tag short-circuits without enumerating spools;keep_spool_idguards 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 viewportsrect.bottom + 4 + popover_height(~320px) > viewport.heightand the popover rendered partially or entirely off-screen. Fix extracts acomputePopoverPosition()helper infrontend/src/utils/popoverPosition.tsthat 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 everyams_filament_dryingcommand (multiple start / stop attempts onams_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 — whetherresult: "fail"and the specificreasoncode — butbambu_mqtt.py:918was only logging the response command name, not the body. The existingextrusion_cali_*/ams_filament_settingdebug path at:919-920was the template; this PR extends it toams_filament_dryingat 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 insidesend_drying_commandthat 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 — flippingclose_power_conflict: trueor 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.tsxand 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 viatoLocaleDateString(), which is local-tz aware. Same data, two renderers, only one was tz-correct. He confirmed with DB query: rows 29 and 30 stored as2026-05-18 ... UTCwere both localMay 17(20:46 CDT and 22:39 CDT), and the Archives → Print Log view formatted them correctly as May 17 viatoLocaleString()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 viaday.toISOString().split('T')[0](thedayDate objects produced by the calendar-generation loop are local-tz constructed vianew Date()+setDate, sotoISOString()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 usednew 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 alocalDateKey(input: string | Date): stringhelper infrontend/src/utils/date.tsthat wrapsparseUTCDate()and formats via the local-tz getters (getFullYear/getMonth/getDatewith 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_atunchanged); 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 usinggetUTC*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 alocalDateKeyhelper 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 viaparseUTCDate. 74 date-util tests green; frontend build clean. Tests are written tz-independently — they constructnew 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 failschange (b51598e). That commit added a pre-insert MQTT probe toPOST /printers/viaprinter_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 fixedawait asyncio.sleep(2)checkedstate.connectedexactly 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 atbambu_mqtt.py:3181), so the probe falsely rejected a printer that would have connected fine. Second, thefinally: client.disconnect()call ran synchronously on the asyncio thread —BambuMQTTClient.disconnect()ends in paho'sloop_stop()whichjoin()s the network thread, and if that thread was still mid-TLS-handshake to the slow P1S socket when teardown ran, thejoin()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-forgetconnect_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 momentstate.connectedflips True — so happy-path connects still finish in ~1–2s and slow brokers get the headroom they need — and movesclient.disconnect()toawait asyncio.to_thread(client.disconnect)so paho's thread-join can never block the event loop. The newconnect_printerfrom-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 inconnected=Falseafter 8s of polling, the 400 withcode=printer_connection_failedstill fires, the row is still never persisted. Tests (2 new intest_printer_manager.py):test_test_connection_polls_and_returns_early_on_connectsimulates the P1S timing —connected=Falseat probe start, flips True ~500ms in — and asserts the probe early-returns in under 1.5s withsuccess=True(a regression that reverts to the fixed sleep fails this immediately);test_test_connection_disconnect_runs_off_loopmocks a deliberately-slow blocking disconnect (mirrors paho'sloop_stop()join semantics) and asserts (a)disconnectran 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 existingtest_test_connection_failuretest was patched to overridePROBE_TIMEOUT_SECONDSto 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
Unknowneven after they'd been classified via the Edit Archive modal. He went through the data layer and identified the desync: twofailure_reasoncolumns exist —print_archives.failure_reasonwritten byPATCH /archives/{id}andprint_log_entries.failure_reasonread by the widget (backend/app/services/failure_analysis.py:88).PrintLogEntry.failure_reasongets captured exactly once at print-completion time (backend/app/main.py:3641) by copyingarchive.failure_reason— and at that moment the archive value is stillNULLbecause the user hasn't picked a reason yet. The Edit Archive modal's PATCH route then writes only toprint_archivesvia a genericsetattrloop, never touching the log entry → widget stays stuck onUnknownforever. The reporter confirmed the desync at the DB level (archive.failure_reason = 'Adhesion failure',print_log_entry.failure_reason = NULL). Fix mirrorsfailure_reasonandstatusfrom the PATCH payload to the most recentPrintLogEntryfor that archive (highestid). Latest-only becausearchive.failure_reason/statusalready reflect the latest run's outcome (each reprint clears the archive's reason atmain.py:2195and 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_idetc 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_gramsatmain.py:596). Tests (3 new intest_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/andfrontend/src/pages/spoolbuddy/, not as a separate codebase. Part 1 — ID surface, seven spots:#<id>in muted small monospace added toLinkSpoolModal.tsx(the link-tag-to-spool picker — the reporter's primary use case),SpoolBuddyWriteTagPage.tsx(write-tag picker — reporter's second screenshot),AssignToAmsModal.tsxheader (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), andSpoolBuddyAmsPage.tsxAMS-slot assigned-spool block. All seven placements mirror the #1385 pattern (#<id>withshrink-0so 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 hardcodedapi.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-tagroute 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 (InventoryPageRouterat:445): page detectsspoolmanModefrom agetSpoolmanSettingsquery at the top and threads it through, withenabled: spoolmanModeReadygating 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 onspoolmanMode— 6 sites: the main spool list, the NewSpoolTouchForm's autocomplete spool list, the untag flow (linkTagToSpoolvslinkTagToSpoolmanSpool— the Spoolman variant doesn't acceptdata_originsince Spoolman manages that), the K-profile save (saveSpoolKProfilesvssaveSpoolmanKProfiles), single-spool create (createSpoolvscreateSpoolmanInventorySpool), and bulk create (bulkCreateSpoolsvsbulkCreateSpoolmanInventorySpools— the Spoolman variant returns aSpoolmanBulkCreateResultenvelope vs raw array, handled with a duck-typed'created' in resultcheck that mirrorsSpoolFormModal's existing pattern). Same shape rule as [[feedback_sqlite_and_postgres_upfront]] / [[feedback_inventory_modes_parity]]: both modes ship in the same drop, nospoolmanMode ? undefined : ...UI gates. Tests: 3 new inSpoolBuddyWriteTagPage.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 OFFandreads from Spoolman when Spoolman mode is ON— the latter assertsgetSpoolsis 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'smaximum_versionto TLS 1.2 so session resumption is synchronous and the upload completes normally. Implementation follows the pattern just established bycamera_profiles.pyin the #1395 follow-up: a newbackend/app/services/ftp_profiles.pymodule with anFTPProfilefrozen 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 withcap_tls_v1_2=True.ImplicitFTP_TLS.__init__gains a matchingcap_tls_v1_2kwarg;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_PROFILESwhen a new reporter surfaces the same symptom. Considered but rejected the reporter's second proposed change (revert manualtransfercmd+sendallback tostorbinary) — the stated rationale ("raw sendall breaks OpenSSL 3.x framing") is incorrect (CPython'sstorbinaryitself usessendallinternally; the actual socket-level behaviour is identical), the move to manualtransfercmdwas deliberate to dodge A1 hanging instorbinary's synchronousvoidresp(), 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 tostorbinarywould lose that protection. Tests: 9 new intest_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 wiring —ImplicitFTP_TLS(cap_tls_v1_2=True)actually setsssl_context.maximum_version == TLSVersion.TLSv1_2, guards against a future refactor that drops the profile→context wiring while keeping the registry looking correct). 87 existingtest_bambu_ftp.pytests 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 explicitnextTick()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 asinvalidwas 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 existingModelViewerModal.test.tsxtests stay green — the yields are inparseMeshFromDocandparse3MFwhich the tests mock around, and thenextTickhelper 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_retriessnapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom ofon_print_start's new-archive branch only — the expected-archive branch (which queue / VP-dispatched / reprinted jobs take, anything registered viaregister_expected_print) exited at its ownreturnwithout 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'sbambuddy-support-20260518-185935.zipshows the failure verbatim:Using expected archive 3 for print (skipping duplicate)at18:41:10,Timelapse was active during print, scheduling auto-scan for archive 3at18: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) — andgrep -i baselineacross 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 ofon_print_start(mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch'sreturn). Helper is best-effort with atry / except Exceptionwrapping_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: newtest_expected_archive_path_captures_timelapse_baselinein the existingtest_print_start_assigns_printer_id_to_vp_archive.pypatches_list_timelapse_videosto return two pre-existing videos, runson_print_startthrough 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_printsetc also clears_timelapse_baselinesnow so test isolation holds.