diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0eab7c0..011a5ed2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to Bambuddy will be documented in this file. - **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean. ### Fixed -- **Assign Spool now configures the slot even after a "Reset Slot" on A1 Mini BMCU / P1S Standard AMS** ([#1322](https://github.com/maziggy/bambuddy/issues/1322) follow-up, reported by @RosdasHH) — The original fix widened empty-slot detection to `state == 11 OR tray_type != ""`, which closed the configured-slot reconfig case (PETG-over-PLA) but didn't help the "Reset Slot on printer screen with spool still inserted" flow: on these firmwares the AMS reports `state=3, tray_type=""` after a Reset Slot regardless of whether a spool is physically loaded. The empty-detection therefore decided "empty", skipped the MQTT publish, marked the assignment pending, and waited for `on_ams_change` to re-fire when the AMS transitioned to "loaded" — but the AMS never transitioned, because nothing was changing physically. A deadlock with no escape from user actions. Reporter pinned it by removing the `if not slot_is_empty:` gate at [`backend/app/api/routes/inventory.py:1302`](backend/app/api/routes/inventory.py) and verified the firmware accepts the MQTT push when a spool is present, even with `state=3, tray_type=""`. The original guard's rationale — "Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for unloaded slots" — turned out to be over-cautious: it's load-bearing only for slots that the *firmware itself* explicitly marks empty via `state == 9` ("no spool") or `state == 10` ("spool present but no feed"). For ambiguous states (`state=3` default-idle, missing-state on older firmwares), the AMS doesn't give us a reliable signal at all, so the safest bet is to treat the user's explicit Assign click as their assertion that a spool is there and let the firmware decide what to do with the push. **Fix:** the empty-detection now only short-circuits on `state ∈ {9, 10}` — every other state attempts MQTT. `pending_config` is now driven by either the explicit-empty signal OR `not configured` (so a printer-offline / no-client publish failure still flags the assignment as awaiting follow-up). The `on_ams_change` replay logic at [`backend/app/main.py:1031`](backend/app/main.py) is unchanged and still serves as the safety net for state=9/10 slots whose spools get inserted later (and for any truly-empty slot the firmware dropped — DB `fingerprint_type` stays empty until an AMS push actually provides one, so the replay still fires). **Trade-off:** for the rare case of "assign to a slot that really IS empty + state=3", the badge will show "Configured" even though firmware silently dropped the push. Most users assign right after inserting, so this is a small UI honesty cost in exchange for unblocking the much more common Reset-Slot workflow. **Regression tests** in [`backend/tests/integration/test_inventory_assign.py`](backend/tests/integration/test_inventory_assign.py): `test_post_reset_slot_with_state_3_still_fires_mqtt` (renamed from the previous "marks_pending" test which was pinning the bug) and `test_state_missing_with_empty_tray_type_still_fires_mqtt` (inverted from the legacy "older firmware empty → pending" assertion) pin the new behavior on the two firmware shapes the reporter hit. `test_empty_tray_type_without_state_still_fires_mqtt` covers the no-state SpoolBuddy case. `test_no_ams_data_with_no_client_marks_pending` keeps the printer-offline path producing `pending_config=True` so on_ams_change replay still triggers. `test_state_empty_skips_mqtt_and_marks_pending` (state=9) is unchanged — the firmware's explicit "no spool" still short-circuits correctly. The recent [`dd3e3f80`](https://github.com/maziggy/bambuddy/commit/dd3e3f80) k-profile fix was a separate red-herring path the reporter happened to also hit during testing; it stays as-is. All 28 inventory-assign tests + 312 inventory-tagged tests pass; ruff clean. +- **Assign Spool now configures the slot even after a "Reset Slot" on A1 Mini BMCU / P1S Standard AMS** ([#1322](https://github.com/maziggy/bambuddy/issues/1322) follow-up, reported by @RosdasHH) — The original fix widened empty-slot detection to `state == 11 OR tray_type != ""`, which closed the configured-slot reconfig case (PETG-over-PLA) but didn't help the "Reset Slot on printer screen with spool still inserted" flow: on these firmwares the AMS reports `state=3, tray_type=""` after a Reset Slot regardless of whether a spool is physically loaded. The empty-detection therefore decided "empty", skipped the MQTT publish, marked the assignment pending, and waited for `on_ams_change` to re-fire when the AMS transitioned to "loaded" — but the AMS never transitioned, because nothing was changing physically. A deadlock with no escape from user actions. Reporter pinned it by removing the `if not slot_is_empty:` gate at [`backend/app/api/routes/inventory.py:1302`](backend/app/api/routes/inventory.py) and verified the firmware accepts the MQTT push when a spool is present, even with `state=3, tray_type=""`. The original guard's rationale — "Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for unloaded slots" — turned out to be over-cautious: it's load-bearing only for slots that the *firmware itself* explicitly marks empty via `state == 9` ("no spool") or `state == 10` ("spool present but no feed"). For ambiguous states (`state=3` default-idle, missing-state on older firmwares), the AMS doesn't give us a reliable signal at all, so the safest bet is to treat the user's explicit Assign click as their assertion that a spool is there and let the firmware decide what to do with the push. **Fix:** the empty-detection now only short-circuits on `state ∈ {9, 10}` — every other state attempts MQTT. `pending_config` is now driven by either the explicit-empty signal OR `not configured` (so a printer-offline / no-client publish failure still flags the assignment as awaiting follow-up). The `on_ams_change` replay logic at [`backend/app/main.py:1031`](backend/app/main.py) is unchanged and still serves as the safety net for state=9/10 slots whose spools get inserted later (and for any truly-empty slot the firmware dropped — DB `fingerprint_type` stays empty until an AMS push actually provides one, so the replay still fires). **Trade-off:** for the rare case of "assign to a slot that really IS empty + state=3", the badge will show "Configured" even though firmware silently dropped the push. Most users assign right after inserting, so this is a small UI honesty cost in exchange for unblocking the much more common Reset-Slot workflow. **Follow-up optimization (also @RosdasHH):** the reporter then traced the raw MQTT payload and found that P1S / A1 Mini send only `{"id": N}` for a genuinely-empty slot — no `state`, no `tray_type`, no other fields. Without that signal, the assign path was firing one wasted MQTT publish per click on a truly-empty slot (firmware dropped it silently, but still). The AMS parser at [`backend/app/services/printer_manager.py:788`](backend/app/services/printer_manager.py) now detects the bare-tray shape (`len(tray) == 1 and "id" in tray and state is None`) and promotes it to `state=9` — the firmware's explicit "no spool" code — which lets the inventory route's existing `state ∈ {9, 10}` short-circuit apply. The detection is intentionally narrow: the post-Reset-Slot A1 Mini BMCU case sends a populated payload with empty values (state=3, tray_type=""), which has more than one key and stays unaffected — so the #1322 root fix is preserved. **Regression tests** in [`backend/tests/integration/test_inventory_assign.py`](backend/tests/integration/test_inventory_assign.py): `test_post_reset_slot_with_state_3_still_fires_mqtt` (renamed from the previous "marks_pending" test which was pinning the bug) and `test_state_missing_with_empty_tray_type_still_fires_mqtt` (inverted from the legacy "older firmware empty → pending" assertion) pin the new behavior on the two firmware shapes the reporter hit. `test_empty_tray_type_without_state_still_fires_mqtt` covers the no-state SpoolBuddy case. `test_no_ams_data_with_no_client_marks_pending` keeps the printer-offline path producing `pending_config=True` so on_ams_change replay still triggers. `test_state_empty_skips_mqtt_and_marks_pending` (state=9) is unchanged — the firmware's explicit "no spool" still short-circuits correctly. The recent [`dd3e3f80`](https://github.com/maziggy/bambuddy/commit/dd3e3f80) k-profile fix was a separate red-herring path the reporter happened to also hit during testing; it stays as-is. All 28 inventory-assign tests + 312 inventory-tagged tests pass; ruff clean. **Bare-tray follow-up tests:** `test_bare_tray_emulates_state_9` and `test_populated_payload_with_empty_state_3_is_not_promoted` in [`backend/tests/unit/services/test_printer_manager.py`](backend/tests/unit/services/test_printer_manager.py) — the second one is the explicit guard against regressing the #1322 root case by accident. - **Firmware update dialog now survives Cloudflare-blocked or transient outages on `bambulab.com`** ([#1350](https://github.com/maziggy/bambuddy/issues/1350), reported by @K1ngJony) — User's X1C on 01.10.00.00 saw "01.11.02.00 newer · Unavailable" plus the error "Firmware file for 01.11.02.00 is not available from Bambu Lab", and the logs showed repeated `Failed to get Bambu Lab page: 403` warnings. Two problems stacked: (1) `https://bambulab.com/en/support/firmware-download/all` (the page Bambuddy scrapes to extract the Next.js `buildId` used to fetch per-model JSON with download URLs) was returning 403 from the reporter's network — Cloudflare bot protection on bambulab.com is stricter than on the wiki and, prior to the 2026-05-12 compliance audit, the firmware-check service still claimed to be Chrome 120 via UA spoofing. The UA was updated to honest `Bambuddy/1.0` in that audit but `Accept` / `Accept-Language` headers were never sent, so the request still tripped the "bare Python client" signal. (2) The `buildId` was cached in-memory only (1 h TTL), so every backend restart forced a fresh page fetch — meaning the first 403 from the user's network permanently broke download-URL resolution for that session even though the previous run had a perfectly valid buildId. **Fix in [`backend/app/services/firmware_check.py`](backend/app/services/firmware_check.py):** (a) the httpx client now sends `Accept: text/html,application/json,*/*;q=0.8` and `Accept-Language: en-US,en;q=0.9` alongside the existing honest `Bambuddy/1.0` UA — both headers any normal client sends, no impersonation. (b) `_get_build_id()` gained a disk-cache layer at `/firmware/build_id.json`: successful fetches persist `{build_id, fetched_at}` to disk; the in-memory cache (fresh path, 1 h TTL) is checked first, then the disk cache seeds the in-memory slot on cold start, then the live fetch tries to refresh. On 403 or network error, we keep the cached buildId and set a new `download_page_unreachable` flag so callers can render an honest error. (c) `_fetch_all_versions_from_download_page` now retries **once** when a cached buildId returns 404 (Bambu rebuilt the page → invalidate + refetch + retry); on 403 it sets the unreachable flag and gives up gracefully without churning. **Better error message in [`backend/app/services/firmware_update.py`](backend/app/services/firmware_update.py):** when a wiki-listed version has no download URL because `download_page_unreachable` is true, the dialog now says `"Could not reach Bambu Lab's firmware download page to fetch the file URL for X. Version is listed on the Bambu wiki but the download endpoint is unreachable from this network. Try again later, or download the firmware manually from bambulab.com and copy it to the printer's SD card."` instead of the misleading `"Firmware file for X is not available from Bambu Lab"` (which implied Bambu didn't have the file, when actually we just couldn't reach Bambu). Version genuinely not in the catalog still gets the original message. **Regression tests** in [`backend/tests/unit/test_firmware_versions.py`](backend/tests/unit/test_firmware_versions.py): `test_client_headers_identify_honestly_and_send_browser_accept` pins UA + Accept headers, `test_build_id_is_persisted_to_disk` confirms the disk write on success, `test_build_id_falls_back_to_disk_on_403` reproduces the reporter's 403 with a pre-seeded disk cache, `test_download_page_unreachable_flag_set_on_403_json` covers the per-model JSON endpoint 403 path, `test_download_page_retries_once_when_buildid_stale` proves the 404 retry. All 12 firmware tests + ruff clean. diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index e412bce9e..0ec263c15 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -768,6 +768,19 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo if k_value is None and cali_idx is not None and cali_idx in kprofile_map: k_value = kprofile_map[cali_idx] + # P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by + # @RosdasHH): for a truly empty slot the firmware sends only + # {"id": N} — no state, no tray_type, no anything else. Treat + # that as the firmware's "no spool" indicator (state=9) so the + # assign-spool path in inventory.py can short-circuit a MQTT + # publish the firmware would silently drop anyway. The + # post-"Reset Slot" A1 Mini BMCU case sends a populated payload + # (state=3, tray_type="") — different shape, doesn't match this + # guard, still attempts the MQTT push per the #1322 fix. + state_val = tray.get("state") + if state_val is None and len(tray) == 1 and "id" in tray: + state_val = 9 + trays.append( { "id": int(tray.get("id", 0)), @@ -785,7 +798,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo "nozzle_temp_max": tray.get("nozzle_temp_max"), "drying_temp": tray.get("drying_temp"), "drying_time": tray.get("drying_time"), - "state": tray.get("state"), + "state": state_val, } ) # Prefer humidity_raw (actual percentage) over humidity (index 1-5) diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index 24da22108..62dbdf339 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/backend/tests/unit/services/test_printer_manager.py @@ -741,6 +741,60 @@ class TestPrinterStateToDict: assert result["ams"][0]["tray"][0]["tag_uid"] is None assert result["ams"][0]["tray"][0]["tray_uuid"] is None + def test_bare_tray_emulates_state_9(self, mock_state): + """P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by @RosdasHH): + the firmware sends only `{"id": N}` for a truly empty slot. Treat that as + the firmware's "no spool" state (state=9) so the inventory assign-spool + path can short-circuit the doomed MQTT publish. + """ + mock_state.raw_data = { + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "state": 11, "tray_type": "PLA"}, # loaded slot + {"id": 1}, # P1S empty-slot signal — only id + ], + } + ] + } + + result = printer_state_to_dict(mock_state) + trays = result["ams"][0]["tray"] + + assert trays[0]["state"] == 11, "loaded slot keeps its firmware state" + assert trays[1]["state"] == 9, "bare {id} tray must be promoted to state=9" + + def test_populated_payload_with_empty_state_3_is_not_promoted(self, mock_state): + """A1 Mini BMCU / P1S Standard AMS post-Reset-Slot case (#1322 root): + firmware sends state=3 + tray_type="" but with the FULL field set + populated. Must NOT be confused with the bare-tray empty signal — + else inventory.py would short-circuit MQTT and we'd reintroduce the + deadlock the #1322 fix removed. + """ + mock_state.raw_data = { + "ams": [ + { + "id": 0, + "tray": [ + { + "id": 0, + "state": 3, + "tray_type": "", # cleared + "tray_color": "", + "tag_uid": "0000000000000000", + "remain": 0, + } + ], + } + ] + } + + result = printer_state_to_dict(mock_state) + # state stays at 3 — the bare-tray promotion requires the dict to have + # ONLY the id key, not just empty/falsy values for the other fields. + assert result["ams"][0]["tray"][0]["state"] == 3 + def test_zero_tag_uid_becomes_none(self, mock_state): """Verify zero tag_uid is converted to None.""" mock_state.raw_data = {