diff --git a/CHANGELOG.md b/CHANGELOG.md index f44a939db..71b834587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file. - **Slicer Bundle (.bbscfg) import (#1712, reported by @IndividualGhost1905)** — Bundle import never delivered what users expected. BambuStudio's "Export Preset Bundle" only includes user-customised presets; system processes / filaments are deliberately excluded by BS. So a fresh-install user who only used stock processes (the common case) got back a bundle containing their printer + maybe four custom filaments + zero processes. Importing that bundle into Bambuddy and then opening the SliceModal flipped into bundle mode — which constrained the dropdowns to bundle contents only — and surfaced "no presets" for process, blocking slicing on STL (3MF still worked because the embedded process JSON bypasses the dropdown). The first round of #1712 (`d459b6ea`, 2026-05-XX) addressed cross-tier visibility / dedup / banner behaviour but didn't touch the bundle-mode dropdown trap. Investigating the second round made it clear the bundle import wasn't unlocking anything the existing tiers don't already cover — custom presets reach Bambuddy through Bambu Cloud sync, Orca Cloud sync, or Single Preset Import; standard presets come from the sidecar's `/profiles/bundled` route automatically — so bundle mode was a fourth code path delivering no unique value while gating users on a slot they couldn't populate. **What was removed.** Backend: `POST/GET/DELETE /slicer/bundles*` routes, `SliceRequest.bundle` field + `SliceBundleSpec` schema, the bundle-dispatch fork in `library.py::_run_slicer_with_fallback` (cross-class slice-all loop, normal slice branch, `_resolve_target_printer_model` short-circuit), the bundle-context query params on `GET /library/files/{id}/filament-requirements` and `GET /archives/{id}/filament-requirements`, the bundle-fingerprint key in `slice_preview.py`'s LRU cache (back to `(kind, source_id, plate_id, content_hash)`), `SlicerApiService.import_bundle/list_bundles/get_bundle/delete_bundle/slice_with_bundle`, the `BundleSummary` / `BundleNotFoundError` types. Frontend: `BundlePicker` + `BundleStringDropdown` components, `isBundleMode` state and every branch on it in `SliceModal.tsx`, `selectedBundleId` / `bundleProcessName` / `bundleFilamentNames` state, the bundle-mode auto-pick effect, the bundle dispatch shape in `buildSliceBody`, the `bundlesQuery` itself, `SlicerBundle` / `SliceBundleSpec` types, `listSlicerBundles` / `importSlicerBundle` / `deleteSlicerBundle` API methods. The bundle-derived path in `buildCompatibilityIndex` is also gone — the function now only takes the printer-model registry and returns `{bambuModelByShortCode}`. `presetCompatibility` keeps its two remaining paths: the slicer's own `compatible_printers` list on local-imported presets (authoritative when set) and the `@BBL ` name-based fallback against the printer-model registry. Tests: `TestBundleRoutes` / `TestBundleClientMethods` / `TestSliceWithBundle` / `TestBundleAwarePreview` / `TestBundleDispatchShape` classes deleted across `test_slicer_presets.py` / `test_slicer_api.py` / `test_slice_preview.py` / `test_slice_request_schema.py` / `test_library_slice_api.py`; the SliceModal's "Bundle tier" describe block and the bundle-only assertions in `slicerPrinterMatch.test.ts` deleted; `SlicerBundlesPanel.test.tsx` removed; `TestNozzleClassGuard` simplified (no more bundle vs preset request distinction). **What replaces the Settings panel.** `SlicerBundlesPanel` is kept under the same name and slot in `SettingsPage` but now renders a static notice (title: "Slicer Bundles (removed)") explaining the removal and pointing users at Single Preset Import / Bambu Cloud / Orca Cloud, with the slicer sidecar covering stock presets automatically. The notice is permanent and can be removed in a future cleanup. **i18n.** `settings.slicerBundles.*` block replaced with `settings.slicerBundlesRemoved.{title,description,alternatives}` translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) per `feedback_translate_dont_fallback`. `slice.bundle` / `slice.bundleNone` / `slice.bundleAllRequired` keys removed across all locales. Parity check 5106 leaves × 11 locales green. **Migration.** Hard cutover, no automatic preset migration. Users who previously imported bundles will see them disappear from Settings → Slicer Bundles after this drops; their printer preset still lives on the sidecar bundle store but is no longer surfaced. Standard presets from the sidecar's BBL tree cover stock slicing; users who need their customs re-upload them via Single Preset Import or sync via Bambu Cloud / Orca Cloud. **Why this resolves #1712.** shaddowlink's failing path was: import bundle for H2D → bundle has 0 processes (BS-side limitation) → SliceModal flips into bundle mode → process dropdown empty → can't slice STL. Post-removal: same import isn't possible, but the cross-tier preset picker shows H2D processes from the sidecar's standard tier (which always had them — bundle mode was the thing hiding them), filtered by `@BBL H2D` compatibility. STL slicing works without any user action. **Tests:** full backend suite 5907/5907 green; ruff clean; frontend ESLint clean; `npm run build` clean; vitest 158 files / 2118 tests green; i18n parity 5106 leaves × 11 locales green. ### Added +- **Support bundle now includes redacted cached push_status per connected printer** — The existing support bundle (`GET /support/bundle`) shipped `support-info.json` + `bambuddy.log` — useful for triage, but missing the one thing that consistently blocks per-model work: the raw shape of the printer's MQTT push_status payload. Bambu firmware ships per-model config in a different shape for every family — AMS Backup detection was deferred in `85fbd7fc` because the H2D's bit-26 of `print.cfg` doesn't translate to the X1C / P1S / P2S layout and we had no ground-truth samples to map them; the same gap surfaces every time a `vt_tray` / `vir_slot` / `mapping` shape varies across firmware (the P2S `tray_now` fix, the H2D `vir_slot` parsing, the round-5 `vt_tray` overlay fix from #1622 last week all needed wire samples to land). **What's new:** the bundle now contains a `push-status/printer-{i}.json` file per connected printer, indexed against `support-info.json["printers"]`. Each file carries `{model, firmware_version, captured_at, raw_data}` where `raw_data` is the live cached push_status from `BambuMQTTClient.state.raw_data`. Disconnected printers (no MQTT state, or `raw_data` empty) are skipped — there's nothing to capture and an empty file just adds noise. **Redaction (two-pass):** a structural pass via the new `_redact_raw_push_status` helper drops user-private top-level keys anywhere in the tree (`subtask_name`, `gcode_file`, `gcode_file_prepare_percent`, `subtask_id`, `task_id`, `project_id`, `design_id`, `profile_id`, `model_id`, `gcode_state`) — Bambu's per-print filename/cloud-ID surface — and rewrites every `net.info[*].ip` entry to `"0.0.0.0"`, mirroring the LAN-topology leak fixed for the virtual-printer bridge in #1429. **What's deliberately preserved:** `print.cfg`, `print.option`, `ams.*`, `vt_tray`, `vir_slot`, `mapping`, `ams_extruder_map`, hardware fields (`nozzle_diameter`, temperatures, layer counters). These are the fields per-model work depends on. The structural pass then runs through `sanitize_log_content` with the same DB-derived `sensitive_strings` map the log path uses (printer names, serials, IPs, access codes, usernames, Bambu Cloud email) — belt-and-suspenders against any user-named string that leaked into a tray UUID or a sub-brand field. The redactor returns a NEW dict and never mutates the live `state.raw_data` (the dispatcher reads it on every tick; mutation would race the next push). **Why always-on instead of opt-in:** the bundle endpoint is already gated on "debug logging must be enabled" — generating the bundle is an explicit user act, the file downloads to the user's machine before they choose to send it, and forcing a second toggle adds friction without changing the threat model. Once a handful of bundles arrive from new-model users we'll have what we need to unblock AMS Backup awareness in the print-queue deficit check, plus future per-model shape variance. **Tests:** 5 new unit cases in `test_support_helpers.py::TestRedactRawPushStatus` pin the contract — drops the 9 user-private keys, rewrites `net.info[*].ip` while preserving `mask` siblings + sibling `net` keys, preserves `print.cfg` / `ams` / `vt_tray` / `vir_slot` / `mapping` / `ams_extruder_map`, does not mutate input, handles non-dict input gracefully (returns `{}` for None / list / str). Full support test surface 79/79 green (`test_support_helpers.py` + `test_support_api.py`); full backend suite 5937/5937 green with `-n 30`; ruff clean across the backend; frontend untouched but rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`. No migration, no new i18n keys, no schema changes, no frontend changes. - **Re-print / Schedule modal: cross-extruder AMS slot picks on dual-nozzle (#1722, reported by @privatsturm)** — On a dual-nozzle setup (e.g. H2D with AMS A+C wired to the left extruder and AMS B wired to the right), the per-filament slot dropdown in the Re-print and Schedule modals used to hide every slot whose extruder didn't match the filament's slicer-assigned nozzle. A filament the slicer had assigned to the left extruder would only let the user pick from A or C; a right-assigned filament could only pick from B. Users who'd intentionally loaded the required filament into the "other" AMS — for example, AMS B (right side) carrying a colour the slicer had planned to print on the left — couldn't select it, even though the printer can physically run that AMS through its wired extruder. Three slice-output diffs (BambuStudio Desktop, OrcaSlicer Desktop, Bambuddy sidecar) all produced identical filament_map values for the same source 3MF, so the slicer wasn't the source of the asymmetry — Bambuddy's UI filter was. **Behaviour:** every loaded slot is now offered for every filament row in the Re-print and Schedule modals' specific-printer flow, regardless of which extruder it's wired to. The L/R badge on the filament row stays as a visual hint to what the slicer planned; the dropdown now trusts the user to pick based on their physical setup. Single-nozzle printers and FTS-equipped setups are unchanged — both short-circuited the filter already and continue to. Printer firmware accepts or rejects the resulting `ams_mapping` at start-print, so a physically-impossible pick fails loudly rather than silently. **Implementation:** `FilamentMapping.tsx:248-254` carried a guard `f.extruderId === item.nozzle_id` on the slot dropdown's `loadedFilaments` filter; the guard is now removed. The single-nozzle and FTS short-circuits stay. **Tests:** `'still applies the per-nozzle filter when FTS is null'` flipped to `'offers cross-extruder slots in the dropdown without FTS (#1722)'` — same scenario (no FTS, AMS 0 on right, filament asking for left), but now asserts both slots ARE listed. The FTS-installed case (#1162) and the rest of the FilamentMapping suite stay green. Backend untouched; no schema, no i18n. 5/5 FilamentMapping vitests green; 1043/1043 full component sweep green; frontend build clean. - **VP wire-payload dump escape hatch for shape-of-payload triage (#1622 investigation)** — When a virtual printer in non-proxy mode is misbehaving for the slicer-facing surface (AMS slot fields rendering empty, filament dropdown unselectable, K-profile not visible), the existing logs prove the bridge is bound and pushing at 1Hz but don't show what's actually in the wire payload. Without that, "cache is missing fields" is indistinguishable from "the slicer-facing copy is stripping them." Set `BAMBUDDY_VP_DUMP_WIRE=1` and Bambuddy writes the bridge's cached push_status (`/vp_wire/_in.json`) and the periodic 1Hz copy that gets sent to the slicer (`/vp_wire/_out.json`) to disk, overwritten on each tick. Diffing the two answers the bisect question; diffing a misbehaving VP's `_out.json` against a known-good VP's `_out.json` (e.g. P1S vs H2D in the #1622 case) answers the model-shape question. Off by default, no overhead when disabled (single env-var read per tick); env var re-read on every call so toggling without restart works; failures swallowed at debug so a broken dump can never break the 1Hz loop. Implementation lives in `backend/app/services/virtual_printer/_debug.py` with call sites in `mqtt_server.py::_send_status_report` (cached branch only — synthetic fallback is uninteresting for this triage) and `mqtt_bridge.py::_on_printer_raw` (immediately after the merge that produces `_latest_print_state`). 21 unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, atomic tmp+rename writes (no half-written .json visible to a reader), sanitized vp_name (path-separator stripped, empty name falls back to `vp`, .. inside a single filename component is harmless because slashes are collapsed before path construction), per-call env check, dict + bytes + str payload acceptance, swallow-on-OSError. Not gated on debug-logging because the bridge's verbose path is already noisy; this dump is small (one file per direction per VP) and only present when the operator opts in. Diagnostic-only — does not change the bridge data path. - **VP slicer↔printer command-flow trace (#1622 round 2)** — The snapshot dump above answers "is the cached push shape correct?", but the round-1 captures from #1622 ruled that out: P1S AMS payload reaches the slicer byte-identical to what the printer sent, sticky-key preservation works, the visible slot data is intact. The remaining symptom (picking a generic filament in archive mode "unloads" the slot) lives on the command path, not in the periodic push — and the snapshot dump doesn't capture command traffic. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every slicer-originated publish on `device//request` AND every printer-originated response the bridge fans out to the slicer (extrusion_cali_get, ams_filament_setting acks, xcam, system, etc.) to `/vp_wire/_cmd.jsonl`, one JSON line per event with UTC iso timestamp, direction (`slicer_to_bridge` / `printer_to_slicer`), MQTT topic, a `.` grep handle, and the parsed payload. Excludes the cached-as-base 1Hz push (already covered by the snapshot dump) and `pushall`/`get_version` (handled locally, never forwarded). Printer-side captures happen AFTER serial rewrite so the dump matches what the slicer actually saw on the wire. New `append_event` helper in `_debug.py` mirrors the same swallow-on-OSError + sanitized-vp_name + per-call env-check posture as `dump_wire`; bytes payloads are utf-8 decoded then json-parsed with the same `\x00`-tolerance fix from #927 so OrcaSlicer's C-string-null publishes parse cleanly; un-parseable bytes fall back to `{"raw": "..."}` so every line stays valid JSON. Eight additional unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, bytes parsing, trailing-null tolerance, unparseable-fallback, vp_name sanitization, iso timestamp shape, append-multiple-lines, swallow-on-OSError. Diagnostic-only — does not change the publish or fan-out data path. diff --git a/backend/app/api/routes/support.py b/backend/app/api/routes/support.py index 6d8ab3c9b..10b64b4bd 100644 --- a/backend/app/api/routes/support.py +++ b/backend/app/api/routes/support.py @@ -1140,6 +1140,63 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[ return content.encode("utf-8") +# Top-level push_status keys that carry user-private data (filenames, BambuCloud +# IDs). Dropped from the bundled per-printer snapshot. Keep print.cfg / +# print.option / ams / vt_tray / vir_slot / mapping — those are the fields that +# make the snapshot worth shipping (per-model AMS Backup detection, tray-shape +# research, VP regression baselines). +_RAW_DATA_DROP_KEYS = frozenset( + { + "subtask_name", + "gcode_file", + "gcode_file_prepare_percent", + "subtask_id", + "task_id", + "project_id", + "gcode_state", # not sensitive, but mirrors current_print which we strip + "design_id", + "profile_id", + "model_id", + } +) + + +def _redact_raw_push_status(raw: dict) -> dict: + """Strip user-private keys from a cached push_status snapshot. + + Drops the keys in :data:`_RAW_DATA_DROP_KEYS` anywhere in the tree, then + rewrites every entry under ``net.info[*].ip`` to ``"0.0.0.0"``. Mirrors the + LAN-topology leak fixed in the virtual-printer bridge (#1429) — the same + field exposes the printer's local IP plus the gateway/peers it sees. Returns + a NEW dict; the live ``state.raw_data`` is never mutated. + """ + + if not isinstance(raw, dict): + return {} + + def _walk(value): + if isinstance(value, dict): + return {k: _walk(v) for k, v in value.items() if k not in _RAW_DATA_DROP_KEYS} + if isinstance(value, list): + return [_walk(v) for v in value] + return value + + out = _walk(raw) + + # Scrub net.info[*].ip after the structural walk — only meaningful at the + # top level; nested "net" blocks don't appear in Bambu push_status payloads. + net = out.get("net") + if isinstance(net, dict): + info_list = net.get("info") + if isinstance(info_list, list): + net["info"] = [ + ({**entry, "ip": "0.0.0.0"} if isinstance(entry, dict) and "ip" in entry else entry) + for entry in info_list + ] + + return out + + async def _get_recent_sanitized_logs(max_lines: int = 200) -> str: """Get recent log lines, sanitized for inclusion in bug reports.""" # Collect sensitive strings from DB for redaction @@ -1191,6 +1248,35 @@ async def generate_support_bundle( # Add support info JSON zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str)) + # Per-printer cached push_status dump. Bambu firmware ships per-model + # config in a different shape for every family (the bit-26 / print.cfg + # gap that blocked AMS Backup awareness in 85fbd7fc), and shape-of- + # vt_tray / mapping / vir_slot has bitten the VP bridge repeatedly. + # Including the redacted snapshot turns every future support bundle + # into a ground-truth sample for that exact model+firmware. Index + # matches the 1-based ordering in support-info.json["printers"] so a + # maintainer can cross-reference without re-deriving identifiers. + statuses = printer_manager.get_all_statuses() + async with async_session() as db: + db_printers = (await db.execute(select(Printer))).scalars().all() + for i, printer in enumerate(db_printers): + state = statuses.get(printer.id) + if state is None or not state.raw_data: + continue + redacted = _redact_raw_push_status(state.raw_data) + snapshot = { + "model": printer.model or "Unknown", + "firmware_version": state.firmware_version, + "captured_at": datetime.now(timezone.utc).isoformat(), + "raw_data": redacted, + } + # Belt-and-suspenders: pass the JSON text through the string-based + # sanitizer so any user-named string (printer name, serial baked + # into a tray uuid) the structural pass missed still gets caught. + snapshot_json = json.dumps(snapshot, indent=2, default=str) + snapshot_json = sanitize_log_content(snapshot_json, sensitive_strings) + zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json) + # Add log file log_content = _get_log_content(sensitive_strings=sensitive_strings) zf.writestr("bambuddy.log", log_content) diff --git a/backend/tests/unit/test_support_helpers.py b/backend/tests/unit/test_support_helpers.py index 40498cf1e..b9afa079c 100644 --- a/backend/tests/unit/test_support_helpers.py +++ b/backend/tests/unit/test_support_helpers.py @@ -1116,3 +1116,123 @@ class TestCollectGitHubBackupInfo: assert info["providers_used"] == {"github": 2, "gitea": 1} assert info["schedule_enabled_count"] == 2 assert info["last_failure_count"] == 2 + + +class TestRedactRawPushStatus: + """Tests for _redact_raw_push_status() — the bundle dump scrubber.""" + + def test_drops_user_filename_and_cloud_ids(self): + from backend.app.api.routes.support import _redact_raw_push_status + + raw = { + "subtask_name": "private_model.gcode", + "gcode_file": "Metadata/private.gcode", + "subtask_id": "1234567890", + "task_id": "9999", + "project_id": "proj-abc", + "design_id": "design-1", + "profile_id": "p-1", + "model_id": "m-1", + "gcode_state": "RUNNING", + "layer_num": 42, # control: non-sensitive sibling must survive + } + + out = _redact_raw_push_status(raw) + + assert "subtask_name" not in out + assert "gcode_file" not in out + assert "subtask_id" not in out + assert "task_id" not in out + assert "project_id" not in out + assert "design_id" not in out + assert "profile_id" not in out + assert "model_id" not in out + assert "gcode_state" not in out + assert out["layer_num"] == 42 + + def test_redacts_net_info_ip_addresses(self): + from backend.app.api.routes.support import _redact_raw_push_status + + raw = { + "net": { + "conf": 1, + "info": [ + {"ip": "192.168.1.42", "mask": "255.255.255.0"}, + {"ip": "10.0.0.1", "mask": "255.0.0.0"}, + ], + }, + } + + out = _redact_raw_push_status(raw) + + # LAN topology must be scrubbed (mirrors the #1429 VP fix). + assert out["net"]["info"][0]["ip"] == "0.0.0.0" + assert out["net"]["info"][1]["ip"] == "0.0.0.0" + # Non-IP siblings inside the entry survive so the shape stays + # diagnosable (interface count, mask presence, etc.). + assert out["net"]["info"][0]["mask"] == "255.255.255.0" + assert out["net"]["conf"] == 1 + + def test_preserves_print_cfg_and_ams_payloads(self): + """The point of bundling raw_data is keeping these — print.cfg is what + unblocks per-model AMS Backup detection (deferred in 85fbd7fc). + """ + from backend.app.api.routes.support import _redact_raw_push_status + + raw = { + "print": { + "cfg": 0x4000000, # bit-26 — the H2D AMS Backup bit + "option": 12345, + }, + "ams": { + "ams": [ + { + "id": "0", + "humidity": "3", + "tray": [ + {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"}, + ], + } + ] + }, + "vt_tray": {"tray_info_idx": "GFA00", "tray_type": "PLA", "tray_color": "00FF00FF"}, + "vir_slot": [{"id": "0", "tray_type": "PLA"}], + "mapping": [0, 1, 2, 3], + "ams_extruder_map": {"0": 1}, + } + + out = _redact_raw_push_status(raw) + + assert out["print"]["cfg"] == 0x4000000 + assert out["print"]["option"] == 12345 + assert out["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA" + assert out["vt_tray"]["tray_info_idx"] == "GFA00" + assert out["vir_slot"][0]["tray_type"] == "PLA" + assert out["mapping"] == [0, 1, 2, 3] + assert out["ams_extruder_map"] == {"0": 1} + + def test_does_not_mutate_input(self): + """Live state.raw_data must not be touched — the dispatcher reads it on + every tick, mutation would race the next push. + """ + from backend.app.api.routes.support import _redact_raw_push_status + + raw = { + "subtask_name": "secret.gcode", + "net": {"info": [{"ip": "192.168.1.5"}]}, + "print": {"cfg": 1}, + } + original_subtask = raw["subtask_name"] + original_ip = raw["net"]["info"][0]["ip"] + + _redact_raw_push_status(raw) + + assert raw["subtask_name"] == original_subtask + assert raw["net"]["info"][0]["ip"] == original_ip + + def test_handles_non_dict_gracefully(self): + from backend.app.api.routes.support import _redact_raw_push_status + + assert _redact_raw_push_status(None) == {} # type: ignore[arg-type] + assert _redact_raw_push_status([]) == {} # type: ignore[arg-type] + assert _redact_raw_push_status("") == {} # type: ignore[arg-type]