diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5cd5bfa..5402162bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ All notable changes to Bambuddy will be documented in this file. - **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`. ### Fixed +- **Archive 3MFs (and library file bytes) silently deleted from disk on every print completion** ([#1212](https://github.com/bambuman/bambuddy/issues/1212), reported by @abbasegbeyemi; matches private "file disappeared overnight" reports) — Reprint and View G-code on a freshly-completed archive returned 404 with no log line explaining why; the DB row was intact, the archive grid kept showing the entry, but `archive.file_path` pointed at a path that no longer existed on disk. Same shape independently reported by a daily-build user whose `.gcode.3mf` "disappeared by itself overnight" between Saturday's print and Monday morning's reprint attempt. Root cause was a regression introduced by [#1166](https://github.com/maziggy/bambuddy/issues/1166)'s cover-cache pre-population: the dispatch sites in `background_dispatch.py:692`, `background_dispatch.py:896`, and `print_scheduler.py:1897` started caching the **live archive copy** (and library file bytes for the Direct-Print flow) in the shared 3MF download cache so the `/cover` endpoint could skip a redundant FTP transfer to the printer mid-print. The cache itself was originally designed for transient downloads under `archive_dir/temp/` and `clear_3mf_cache(printer_id, delete_files=True)` — called from `on_print_complete` to keep that temp dir from accumulating — happily `unlink()`'d every cached path. Pre-#1166 every cached path was a temp file, so deletion was correct. Post-#1166 the cleanup was destroying user data: every print → archive 3mf cached → on print complete `clear_3mf_cache` walks the cache → `path.unlink()` on the actual archive copy. The `Path.exists()` guard inside `_maybe_unlink` masked the failure: the file existed at unlink time, so no exception, no warning, just silent destruction. The DB row remained, so the UI listing didn't change — only when the user tried to *act* on the archive (reprint / view-gcode / re-export) did the missing file surface as a 404. Affected every daily build since [`889c8bd8`](https://github.com/bambuman/bambuddy/commit/889c8bd8) (Apr 29). **Fix:** `clear_3mf_cache._maybe_unlink` in `backend/app/services/bambu_ftp.py` now refuses to `unlink()` any path outside `archive_dir/temp` — the cache dict is still cleared either way (so re-cache logic continues to work and the cover endpoint still hits a fresh path on the next print), only the on-disk delete is gated. Persistent locations — `archive//...`, `archive/unassigned/...` (VP-archived prints with `printer_id=None`), `library_files/...`, and any `is_external` library mount — survive intact. The dispatch sites that cache those paths are unchanged: it's correct for `/cover` to read straight from the live archive copy and avoid the redundant 36 MB FTP transfer; the only bug was the cleanup branch treating all cached paths as transient. Regression test `test_clear_does_not_delete_persistent_files` in `test_bambu_ftp.py` pins the contract end-to-end: an archive 3mf at `archive/1/.../...gcode.3mf`, a library 3mf at `library_files/...`, and a temp 3mf at `archive/temp/...` are all cached for the same printer; after `clear_3mf_cache(1)` runs, all three cache entries are dropped from the dict (so the cache state is consistent), but only the temp file is unlinked from disk — the archive and library files still exist. Two existing cache tests (`test_clear_by_printer_scoped`, `test_clear_without_deleting_files`) updated to put their fixtures under `archive_dir/temp` since that's now the only path the cleanup will touch. **Damage:** users on daily builds since Apr 29 with a `print → wait for completion → reprint or view-3mf later` workflow have been silently losing archive copies. Recovery for individual users: re-import the source 3mf from your slicer / NAS, or re-archive from the printer's FTP if the file is still there. Going forward the bytes are safe. + - **MakerWorld P2S 3MFs failed to slice with "Param values in 3mf/config error: -1 not in range"** ([#1201](https://github.com/maziggy/bambuddy/issues/1201), reported by @inorichi) — Slicing any MakerWorld model sliced for the P2S (e.g. `https://makerworld.com/en/models/1958872`) bombed with `Slicer process failed (exit code 238)` and stderr listing `raft_first_layer_expansion: -1 not in range [0.0, 3.4e+38]` and `tree_support_wall_count: -1 not in range [0.0, 2.0]`. Root cause: BambuStudio writes `"-1"` into `Metadata/project_settings.config` for fields the user wants inherited from the parent process preset — the GUI handles this internally, but the headless CLI (orca-slicer-api / bambu-studio-api sidecar) runs `StaticPrintConfig`'s range validator against the embedded settings *before* the `--load-settings` overrides apply, so the sentinel `"-1"` trips the field's lower-bound check and the CLI exits non-zero before our profile triplet is ever consulted. The `slice_with_profiles` path failed; the fallback to `slice_without_profiles` (which uses embedded settings only) also failed because it reads the same `project_settings.config` and the same validator runs there too. Earlier in the codebase there's a `_strip_3mf_embedded_settings` function that tried to dodge this by removing the entire `project_settings.config` (plus `model_settings.config`, `slice_info.config`, `cut_information.xml`); that experiment was reverted because the strip broke `StaticPrintConfig` initialisation — silent exit-0, no `result.json`, no stderr, masked by the fallback retry which then produced wrong-printer output without telling anyone (the cautionary comment in `library.py:_run_slicer_with_fallback` records the lesson). **Fix is surgical:** new `_sanitize_project_settings_sentinels(zip_bytes)` opens the embedded config, removes only allowlisted keys when their value is exactly `"-1"`, and re-zips. Allowlist (`_PROJECT_SETTINGS_SENTINEL_KEYS`) starts with the two from this report (`raft_first_layer_expansion`, `tree_support_wall_count`) plus `prime_tower_brim_width` (a known sentinel cited in the strip-experiment comment block from earlier reports). Other fields — including non-allowlisted keys that happen to hold `"-1"` (e.g. `z_offset` set to `-1` deliberately by a user) — are left untouched, so a blanket "-1 strip" can't silently corrupt legitimate negative values. The sanitiser runs before *both* the profile-driven path and the embedded-settings fallback, since both fail on the same input. Defensive fallbacks: returns the original bytes unchanged when the input isn't a valid zip, doesn't contain `project_settings.config`, has no allowlisted sentinels present, the JSON is malformed, or the config root isn't a dict — so the caller can pass the result on without further checks. Geometry, thumbnails, color, multi-part data, and every other zip entry round-trip byte-identical (the previous full-strip experiment's failure mode can't reoccur). 13 new unit tests in `test_project_settings_sentinel_sanitiser.py` pin the contract: each allowlisted key removed when value is `"-1"` (parametrised across the allowlist); multiple sentinels removed at once; allowlisted key with legitimate non-sentinel value (`"0"`) preserved; non-allowlisted key holding `"-1"` (`z_offset`) preserved; identity return when nothing needs sanitising; array-form values (per-filament/per-extruder lists) left alone (v1 handles scalar strings only, expand later if needed); other zip entries (model_settings.config, slice_info.config, _rels metadata, geometry) all preserved with byte-identical content; non-zip input passes through; missing `project_settings.config` passes through; malformed JSON passes through; non-dict JSON root passes through. **Adding new sentinel keys:** if a future report surfaces another field name in the slicer's `: -1 not in range [...]` error, add the field to `_PROJECT_SETTINGS_SENTINEL_KEYS` — the rest of the code stays unchanged. - **Archive created with wrong plate metadata when consecutive plates of the same model are printed back-to-back** ([#1204](https://github.com/maziggy/bambuddy/issues/1204), reported by @BurntOutHylian) — Print Plate 2 of any multi-plate project, let it complete, then immediately print Plate 1: the resulting archive was named "MyModel - Plate 2" with Plate 2's filament slots and slicer estimate, even though Plate 1 was the print actually running. Root cause was an MQTT lag in the `print_start` data: the trigger fires on a `gcode_file` change (`bambu_mqtt.py:2781-2786` — the field carrying `/data/Metadata/plate_N.gcode`, which is plate-specific and always fresh), but `subtask_name` (model-level, e.g. "MyModel - Plate 2") can still echo the previous job in the same MQTT batch. The FTP candidate list in `main.py:1974` is built from `subtask_name` first, so the previous Plate 2 upload — still resident on the printer's FTP from the just-completed print — got picked up and fed into archive creation. The 3MF parser then read `_plate_index=2` from the wrong file's `slice_info.config` and locked Plate 2's name + estimate + per-slot filament data into the row at creation, with no follow-up to correct. Reporter @BurntOutHylian's diagnosis nailed it: the parser already extracts `_plate_index` from inside the 3MF (`archive.py:154`), and `parse_plate_id()` (`printer_manager.py:678`) already extracts the plate from `gcode_file` — those two values just weren't being compared. **Fix:** new helpers `peek_plate_index_in_3mf()` (cheap zip read of `Metadata/slice_info.config` only, returning the plate index) and `swap_plate_suffix()` (rewrites trailing " - Plate N" or "_plate_N" — both forms appear in real subtask_names, see `test_print_start_expected_promotion`) in `archive.py`. After a successful FTP download in `_handle_print_start`, the new validation block in `main.py` peeks the downloaded 3MF's plate index, compares against `parse_plate_id(filename)`, and on mismatch retries the FTP fetch with a corrected `subtask_name`. If the retry finds a 3MF whose plate matches, the wrong file is dropped and the corrected one is used — archive name + estimate + slots all reflect the actual plate. If the retry can't find a matching file (or no swap is possible because `subtask_name` had no plate suffix to swap), the wrong 3MF is dropped and the existing no-3MF fallback (`main.py:2155`) creates an archive without metadata; the stale `subtask_name` is overridden to the corrected one (or cleared so `filename` wins) so the fallback's `print_name` at least reflects the right plate rather than locking in a misleading name. The validation only fires when `parse_plate_id(filename)` returns a value, so single-plate / non-Bambu / cloud-named jobs are unaffected. **Defence in depth:** the cache eviction is implicit — `temp_path.unlink()` makes the wrong-file cache entry self-clean on next access via the existing `get_cached_3mf` evict-on-miss path (`bambu_ftp.py:660-664`); no separate cache invalidation needed. 17 new unit tests in `test_archive_plate_validation.py` pin the helpers: `peek_plate_index_in_3mf` returns the index for a valid 3MF, None for missing slice_info, None for missing index metadata, None for non-zip files, None for missing files, None for non-integer index values; `swap_plate_suffix` handles the spaced "Plate N" form (capitalised + lowercase + tight-hyphen), the underscored "_plate_N" form (the `Box3.0_(2)_plate_5` case from the existing fixture), case-insensitive matching, returns None for names without a recognised suffix, returns None for None input, and preserves separator casing so the corrected name matches what BambuStudio actually uploaded. diff --git a/backend/app/services/bambu_ftp.py b/backend/app/services/bambu_ftp.py index fba2bd38e..e1a565abe 100644 --- a/backend/app/services/bambu_ftp.py +++ b/backend/app/services/bambu_ftp.py @@ -670,14 +670,32 @@ def clear_3mf_cache(printer_id: int | None = None, delete_files: bool = True) -> When ``delete_files`` is True (default) the on-disk 3MF is removed as well — called from on_print_complete so temp files don't accumulate across prints. Tests that want to inspect the cache contents disable this. + + Only paths inside ``archive_dir/temp`` are unlinked. The dispatch sites + added in #1166 also cache the live archive copy and library file bytes + so /cover can skip FTP — those are *user data*, never the cache's to + delete. Pre-fix this branch silently removed archive 3mfs on every print + completion (#1212 + private reports of "file disappeared overnight"). """ + from backend.app.core.config import settings as _config_settings + + temp_root = _config_settings.archive_dir / "temp" + + def _is_temp_path(path: Path) -> bool: + try: + return path.is_relative_to(temp_root) + except (OSError, ValueError): + return False def _maybe_unlink(path: Path) -> None: - if delete_files and path.exists(): - try: - path.unlink() - except OSError as exc: - logger.debug("3MF cache cleanup skipped %s: %s", path, exc) + if not delete_files or not path.exists(): + return + if not _is_temp_path(path): + return + try: + path.unlink() + except OSError as exc: + logger.debug("3MF cache cleanup skipped %s: %s", path, exc) if printer_id is None: for path in list(_threemf_path_cache.values()): diff --git a/backend/tests/unit/services/test_bambu_ftp.py b/backend/tests/unit/services/test_bambu_ftp.py index d5c7567af..11a99324c 100644 --- a/backend/tests/unit/services/test_bambu_ftp.py +++ b/backend/tests/unit/services/test_bambu_ftp.py @@ -1198,26 +1198,78 @@ class TestThreeMFCache: cache_3mf_download(1, "A.3mf", f) assert get_cached_3mf(1, "A.3mf") == f - def test_clear_by_printer_scoped(self, tmp_path): + def test_clear_by_printer_scoped(self, tmp_path, monkeypatch): """Clearing one printer leaves the other untouched.""" - f1 = tmp_path / "one.3mf" + from backend.app.core import config as _config + + monkeypatch.setattr(_config.settings, "archive_dir", tmp_path) + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + f1 = temp_dir / "one.3mf" f1.write_bytes(b"1") - f2 = tmp_path / "two.3mf" + f2 = temp_dir / "two.3mf" f2.write_bytes(b"2") cache_3mf_download(1, "one.3mf", f1) cache_3mf_download(2, "two.3mf", f2) clear_3mf_cache(1) assert get_cached_3mf(1, "one.3mf") is None assert get_cached_3mf(2, "two.3mf") == f2 - # clear_3mf_cache defaulted to delete_files=True, so the file is gone + # clear_3mf_cache defaulted to delete_files=True, so the temp file is gone assert not f1.exists() assert f2.exists() - def test_clear_without_deleting_files(self, tmp_path): + def test_clear_without_deleting_files(self, tmp_path, monkeypatch): """delete_files=False leaves files on disk — used by tests.""" - f = tmp_path / "keep.3mf" + from backend.app.core import config as _config + + monkeypatch.setattr(_config.settings, "archive_dir", tmp_path) + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + f = temp_dir / "keep.3mf" f.write_bytes(b"x") cache_3mf_download(1, "keep.3mf", f) clear_3mf_cache(1, delete_files=False) assert get_cached_3mf(1, "keep.3mf") is None assert f.exists() + + def test_clear_does_not_delete_persistent_files(self, tmp_path, monkeypatch): + """Regression for #1212 / "file disappeared overnight" reports. + + Dispatch sites added in #1166 cache the live archive copy and library + file bytes — paths outside ``archive_dir/temp`` — so /cover can skip + FTP. Those files are user data; the cache cleanup must never unlink + them. Pre-fix, ``clear_3mf_cache(printer_id, delete_files=True)`` ran + on every ``on_print_complete`` and silently destroyed them, leaving a + DB row whose ``file_path`` pointed at nothing — breaking Reprint and + View G-code with a 404. + """ + from backend.app.core import config as _config + + monkeypatch.setattr(_config.settings, "archive_dir", tmp_path / "archive") + (tmp_path / "archive" / "temp").mkdir(parents=True) + + archive_file = tmp_path / "archive" / "1" / "20260504_wallhooks" / "wallhooks.gcode.3mf" + archive_file.parent.mkdir(parents=True) + archive_file.write_bytes(b"archive bytes") + + library_file = tmp_path / "library_files" / "abcd.3mf" + library_file.parent.mkdir(parents=True) + library_file.write_bytes(b"library bytes") + + temp_file = tmp_path / "archive" / "temp" / "cover_1_x.3mf" + temp_file.write_bytes(b"temp bytes") + + cache_3mf_download(1, "wallhooks.gcode.3mf", archive_file) + cache_3mf_download(1, "library.3mf", library_file) + cache_3mf_download(1, "cover_1_x.3mf", temp_file) + + clear_3mf_cache(1) + + # All three cache entries are dropped from the dict. + assert get_cached_3mf(1, "wallhooks.gcode.3mf") is None + assert get_cached_3mf(1, "library.3mf") is None + assert get_cached_3mf(1, "cover_1_x.3mf") is None + # But only the temp file is unlinked — user data survives. + assert archive_file.exists(), "archive 3mf must not be deleted by cache cleanup" + assert library_file.exists(), "library 3mf must not be deleted by cache cleanup" + assert not temp_file.exists(), "temp file should still be cleaned up"