diff --git a/CHANGELOG.md b/CHANGELOG.md index 6154eac65..937234906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ All notable changes to Bambuddy will be documented in this file. - **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 +- **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::TestDryingCompleteCallback` — `test_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 ``. Modern BambuStudio 3MFs wrap filaments inside `` 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 `` 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 `` 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. diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index e8b69b610..795260181 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -1714,8 +1714,15 @@ class BambuMQTTClient: merged_trays.append(merged_tray) else: merged_trays.append(new_tray) - # Update ams_unit with merged trays - ams_unit = {**ams_unit, "tray": merged_trays} + # Update ams_unit with merged trays. Spread existing_unit + # FIRST so top-level fields the partial update omits — + # dry_time, info (which drives dry_status / dry_sub_status), + # humidity, temp — are preserved instead of dropped. The + # printer sends tray-bearing partials that carry no drying + # fields; without this, dry_time reads as absent → 0 and the + # falling-edge detector below fires a false "drying complete" + # (#1462). Mirrors the no-tray branch's merge semantics. + ams_unit = {**existing_unit, **ams_unit, "tray": merged_trays} elif existing_unit: # Partial update without tray data: merge new fields into existing # unit to preserve tray, sn, sw_ver, and other accumulated data. @@ -1872,10 +1879,18 @@ class BambuMQTTClient: continue if ams_id < 0: continue + # Only evaluate the edge when this update carries an explicit + # dry_time. An absent / unparseable value is NOT zero — treating + # it as 0 lets a tray-only partial fake a drying-complete edge + # (#1462). Skip without touching the remembered value so the + # next update that DOES carry dry_time sees the true previous. + raw_dry_time = ams_unit.get("dry_time") + if raw_dry_time is None: + continue try: - current = int(ams_unit.get("dry_time") or 0) + current = int(raw_dry_time) except (TypeError, ValueError): - current = 0 + continue previous = self._previous_dry_times.get(ams_id, 0) self._previous_dry_times[ams_id] = current if previous > 0 and current == 0: diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 642a90e67..ca134027d 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -5132,3 +5132,23 @@ class TestDryingCompleteCallback: # And finishes. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]}) assert mqtt_client._drying_events == [0, 0] + + def test_tray_only_partial_does_not_fake_completion(self, mqtt_client): + """#1462 — a tray-bearing partial update that omits dry_time must not + be read as dry_time=0. The pre-fix merge dropped dry_time on such + partials, so the falling-edge detector saw a 60→0 edge and fired a + false 'drying complete' seconds after drying started — which armed + smart-plug auto-off and killed the printer mid-cycle.""" + # Drying active, 60 minutes remaining. + mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 60, "tray": []}]}) + assert mqtt_client._drying_events == [] + + # Printer sends a tray-bearing partial carrying NO dry_time field. + mqtt_client._handle_ams_data({"ams": [{"id": "0", "tray": []}]}) + assert mqtt_client._drying_events == [] + # dry_time survived the partial in the merged AMS state. + assert mqtt_client.state.raw_data["ams"][0]["dry_time"] == 60 + + # Drying genuinely finishes → the real edge still fires exactly once. + mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]}) + assert mqtt_client._drying_events == [0]