diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6a55000..0a64a86d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file. - **Trivy DS-0026 (`Dockerfile.test` missing HEALTHCHECK): silenced via `HEALTHCHECK NONE`** — The test image runs `pytest` and exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise. `HEALTHCHECK NONE` is the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813. ### Fixed +- **Sliced `.gcode.3mf` files now render in the 3D preview and expose a Preview-3D action in the file row (#1543, reported by @Vlado-Tarakan)** — Reporter exported a multi-plate `.gcode.3mf` from Bambu Studio to the shared folder Bambuddy watches and the 3D preview tab came up empty; if he re-uploaded the same file via the file manager, the preview worked. Root cause: two paths classify `file_type` differently. `backend/app/api/routes/library.py:1343-1348` (the shared-folder scan path) does a compound-extension check and tags the file `gcode.3mf`; the upload path at the same file's `1588` does a single `ext[1:]` and tags it `3mf`. Then `frontend/src/components/ModelViewerModal.tsx:71-73` had `hasModel = normalizedType === '3mf' || 'stl'` and `hasGcode = normalizedType === 'gcode' || '3mf'` — neither matched `gcode.3mf`, so the capabilities object landed with both flags false and the modal rendered an empty bed. `FileManagerPage.tsx:858` also gated the Preview-3D context action on `file_type === '3mf' || 'gcode' || 'stl'`, so for shared-folder files the entry didn't even appear, and the type pill at `765-770` had no colour case for `gcode.3mf` so it fell through to the generic gray. **Fix** (frontend-only, no backend churn): `ModelViewerModal.tsx` introduces an `isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'` predicate used in two places — the capabilities branch (`hasModel = isThreeMfFamily || 'stl'`, `hasGcode = isThreeMfFamily || 'gcode'`) and the plates-loading branch that previously hard-gated on `!== '3mf'` and would have returned `setPlatesData(null)` for the shared-folder file. `FileManagerPage.tsx` adds `gcode.3mf` to the Preview-3D action gate and shares the gcode blue type-pill colour so sliced-output files are visually distinguishable from source 3MFs. The compound `gcode.3mf` classification on the backend is intentionally preserved — it carries useful "this is a sliced output" semantics that other UI surfaces could use later. The `canOpenInSlicer` and `sliceableType` checks at `ModelViewerModal.tsx:269, 277-280` are deliberately left alone — a sliced output isn't openable in the slicer, and `sliceableType` already explicitly excludes `.gcode` and `.gcode.3mf` per the comment "the file type can't be sliced". **Out of scope** (separate Bambu-Studio format limitation, not a Bambuddy bug): Vlado's secondary observation that the upload-path 3D preview "shows only one plate" even though his project has 5 plates — Bambu Studio's `.gcode.3mf` export contains the g-code and model data for the active plate only, not the entire multi-plate project. The print picker enumerates plates via `gcode_*.gcode` entries inside the zip (a separate code path), which is why the user can still pick the plate at print time. The empty-bed fix is the data point that closes the user-visible bug. **Tests**: existing full 2043-test frontend suite green; no test asserted on the unsupported `gcode.3mf` capabilities branch (the change is additive — `3mf` and `stl` and `gcode` behaviours are unchanged). Frontend build clean. - **Connected-edge reconciliation closes the missed-PRINT-COMPLETE loop that produced ghost replays on smart-plug power cycles (#1542 follow-up, reported by @vixussrl-ui)** — Reporter ran a fresh trace after the doubled-extension fix landed and found a distinct second cause behind his ghost prints, hitting 4-of-4 of his A1s. Timeline: 22:50 PRINT START → print runs all night → MQTT disconnects multiple times (A1's keepalives are unstable on his network) → print finishes during one of those disconnect windows so PRINT COMPLETE is never observed → smart plug cuts power on idle → power resumes for the next scheduled print → firmware auto-replays the leftover `.3mf` from the SD card → Bambuddy reconnects to a fresh PRINT START for the ghost. The existing IDLE-after-RUNNING completion check at `backend/app/services/bambu_mqtt.py:3022` was meant to catch the simple disconnect-then-finish case via `_previous_gcode_state` preserved across reconnects, but with multiple disconnect/reconnect cycles + a smart-plug power-off that Bambuddy can't distinguish from any other transient drop, the IDLE window that branch needs simply never reaches it. The SD `.3mf` lingers, the firmware ghost-replays every power cycle, and the loop repeats until the operator notices. **Fix**: a new connected-edge reconciliation pass — new `reconcile_stale_active_prints(printer_id)` in `backend/app/main.py` queries archives in `status="printing"` for the printer at MQTT (re)connect time and synthesises `on_print_complete(status="aborted")` for any whose print can't actually be running anymore. The decision is made by a pure `_is_active_archive_stale(archive, state)` function with three triggers: (1) current printer state is terminal (IDLE / FINISH / FAILED) — covers the clean disconnect-then-finish case the existing #3022 branch was already trying to handle; (2) printer is running but with a different `subtask_id` than the archive — Bambu firmware mints a fresh `subtask_id` for each print including the ghost-replay it runs after a power cycle, so a mismatch is unambiguous evidence the in-DB archive is no longer the print on the printer; (3) printer is running but `subtask_name` is empty — the printer doesn't know what it's running, archive reference is broken. PAUSE / PREPARE / SLICING / RUNNING with matching subtask are intentionally left alone — false positives there cost a single misreported "aborted" status that the real PRINT COMPLETE would have overwritten anyway, while a false negative is the ghost-print loop being reported. The synthesised `on_print_complete` reuses the existing chain (SD cleanup, status update, usage tracker, notifications) — no reimplementation, no duplicate event when real completion later fires (the second call sees `status != "printing"` and falls through). Status `"aborted"` is the conservative label; we have no progress evidence to promote to `"completed"`. **Wiring**: new `_printer_reconciled_since_connect: dict[int, bool]` edge tracker at module scope, checked at the start of `on_printer_status_change` — when `state.connected` flips False → True (which covers both Bambuddy startup with no prior connection AND a mid-session MQTT reconnect), reconciliation fires exactly once for that connection. Setting the edge to True BEFORE the spawned task starts prevents concurrent status updates within the same connection from re-triggering it. **Concurrency**: reconciliation runs as `asyncio.create_task` so it doesn't block the WebSocket dedup / broadcast logic that on_printer_status_change is the hot path for. **Ghost-print collateral worth being explicit about**: if the ghost is already running when reconciliation fires, the synthesised SD-cleanup will hit 550-file-locked (firmware locks the file during print, same cause as the #1542 first case). The cleanup retries 3× then logs "lingering" — same as any other in-print cleanup attempt. The ghost runs to completion, its own end-of-print cleanup deletes the file, and the next power cycle has nothing to replay. The loop breaks even when reconciliation can't physically delete the file mid-ghost. A perfect cancel would require sending a `print_stop` MQTT command to the printer, which is invasive and explicitly out of scope. **Tests**: 21 in `test_reconcile_stale_active_prints.py` — `TestIsActiveArchiveStale` covers all three stale triggers with case-insensitive state matching, the four healthy-no-op cases (RUNNING / PAUSE / PREPARE / SLICING with matching subtask), the IDLE-overrides-subtask-match precedence, and the missing-subtask_id edge cases that fall through to the subtask_name check. `TestReconcileStaleActivePrints` covers the orchestrator: no-status, disconnected-status, and no-active-archives all short-circuit; a stale archive produces a synthesised `on_print_complete(status="aborted", _reconciled=True)` payload with the archive filename; a healthy in-flight archive doesn't fire any completion; an exception inside one archive's synthesis doesn't block the rest or propagate to the caller. Full 5399-test backend suite green (5378 + 21 new). Backend ruff clean. - **Fallback-archive MQTT filament extraction now actually fires for real prints (#1533 follow-up, reported by @JmanB52D)** — Reporter updated to 0.2.5b1 expecting the #1533 fix to populate filament fields on his P2S virtual-printer prints when the .3mf is locked. His support bundle showed Bambuddy still creating fallback archives with NULL filament fields even though the print-start log line proved AMS-0-T0 had PETG loaded at the moment the helper should have read it (`AMS 0: T0(type=PETG, color=FFFFFFFF, …)`). Cause: the #1533 helper `_extract_filament_data_from_mqtt(data)` in `backend/app/main.py` only looked at `data["ams"]`, but the dict that `on_print_start` actually receives at runtime is the wrapper shape `{"filename", "subtask_name", "remaining_time", "raw_data": , "ams_mapping"}` that `backend/app/services/bambu_mqtt.py:2971-2980` constructs — so `data["ams"]` was undefined on every real call and the helper silently returned `{}`, leaving the fallback archive's `filament_type` / `filament_color` NULL. The 15 unit tests that shipped with #1533 all passed the bare inner shape directly and never exercised the callback wiring, so the regression slipped through the green build. **Fix**: the helper now resolves `data["raw_data"]["ams"]` first (the callback shape) and only falls back to `data["ams"]` when the wrapper isn't present (preserves the inner-shape callers from the existing tests). Defensive: a non-dict `raw_data` (e.g. partial MQTT decode failure) falls through to the inner lookup instead of crashing. **Tests**: 5 new in `TestOnPrintStartCallbackShape` (`backend/tests/unit/test_fallback_archive_mqtt_filament.py`) — wrapper payload with ams_mapping resolves to the inner data; wrapper with no ams_mapping lists all loaded slots; the existing inner-shape callers still work after the additive wrapper lookup; missing `raw_data` returns `{}` instead of raising; junk `raw_data` (string) doesn't shadow a present inner `ams`. Full 5378-test backend suite green. Backend ruff clean. **What this does NOT fix**: per-filament gram usage still needs the actual .3mf — the printer locks it during print (P-line firmware behaviour, not a Bambuddy bug), and the existing 19 FTP candidate paths + directory probes are expected to 550 in that window. Per-print filament type and colour are the data point that drives the AMS-expansion planning the reporter explicitly called out, so this is the fix that moves the needle for him. - **Assigning a spool no longer shows a profile-mismatch warning when only the slicer profile differs, and the warning now states the AMS slot will be reconfigured (#1552, reported by @anthonyma94)** — Reporter assigned a spool to a slot whose stored slicer profile (e.g. "Bambu PLA Matte") differed from the new spool's profile (e.g. "Bambu PLA Basic"), got a warning popup with only Cancel / Assign Anyway, and was under the impression that confirming the popup just linked the spool in Bambuddy's DB without touching the AMS — i.e. that he then had to manually open Configure AMS Slot to push the new profile to the printer. The auto-push has actually been in place since the assign route existed: `backend/app/api/routes/inventory.py::assign_spool` calls `apply_spool_to_slot_via_mqtt` after upserting the SpoolAssignment row, which publishes both `ams_filament_setting` (tray_info_idx, tray_sub_brands, color, temps) and `extrusion_cali_sel` (K profile) over MQTT, and `backend/app/api/routes/spoolman_inventory.py::assign_spoolman_slot` does the same on the Spoolman side. The only short-circuit is when the firmware explicitly reports the slot empty (`tray_state ∈ {9, 10}`), in which case `main.py::on_ams_change` deferred-replays the configure as soon as a spool appears. So the popup was creating friction without revealing what it actually did. **Two changes**: (1) `AssignSpoolModal.tsx` + `spoolbuddy/AssignToAmsModal.tsx` no longer fire the mismatch popup for *profile-only* mismatches — `if (materialMatchResult !== 'exact')` replaces the old `materialMatchResult !== 'exact' || !profileMatches`, and the `'profile'` member is dropped from the `mismatchType` union (the standalone profile branch in both popup render bodies is removed as dead code). Material mismatch — where Bambu firmware can refuse the print because the type is wrong — still warns. (2) Every firing warning (material, partial, material+profile, partial+profile) now appends a new line via the new `inventory.assignReconfigureNote` i18n key: "The AMS slot will be reconfigured to use the spool's profile." This makes the Assign Anyway button's effect explicit instead of leaving users to guess. **i18n**: real translations across all 9 locales per [[feedback_translate_dont_fallback]]; parity script clean at 4999 leaves per locale. **Tests**: existing 14 `AssignSpoolModal` + 7 `AssignToAmsModal` tests pass unchanged — no test asserted on the profile-only popup firing. Frontend build clean, full 2043-test suite green. **Open follow-up**: if anthonyma94 confirms after this change that his slot *still* shows the old profile after Assign Anyway, the real bug is in `apply_spool_to_slot_via_mqtt`'s tray_info_idx / setting_id resolution for his specific spool shape — would need his spool's `slicer_filament` value plus the live tray state to diagnose. diff --git a/frontend/src/components/ModelViewerModal.tsx b/frontend/src/components/ModelViewerModal.tsx index 6c22e6695..8e95348a4 100644 --- a/frontend/src/components/ModelViewerModal.tsx +++ b/frontend/src/components/ModelViewerModal.tsx @@ -69,8 +69,15 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on if (isLibrary) { const normalizedType = (fileType || '').toLowerCase(); - const hasModel = normalizedType === '3mf' || normalizedType === 'stl'; - const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf'; + // A `.gcode.3mf` file is the slicer's sliced output — it carries + // both the per-plate model (in `3D/3dmodel.model`) and the g-code + // for the active plate (in `Metadata/plate_*.gcode`). The backend + // library scan path (library.py) tags it `gcode.3mf` while the + // upload path tags it `3mf`, so we accept both shapes here for + // the 3D-tab + g-code-tab gating (#1543). + const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'; + const hasModel = isThreeMfFamily || normalizedType === 'stl'; + const hasGcode = isThreeMfFamily || normalizedType === 'gcode'; setCapabilities({ has_model: hasModel, has_gcode: hasGcode, @@ -116,7 +123,10 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on if (isLibrary) { const normalizedType = (fileType || '').toLowerCase(); - if (!libraryFileId || normalizedType !== '3mf') { + // Same 3mf-family gate as the capabilities branch above — sliced + // `.gcode.3mf` files have plate metadata too (#1543). + const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'; + if (!libraryFileId || !isThreeMfFamily) { setPlatesData(null); setPlatesLoading(false); return; diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 0356325ac..de5347cd5 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -763,7 +763,9 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, {/* File type badge */}
@@ -855,7 +857,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, {t('slice.action')} )} - {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && ( + {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf') && (