diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed07c74e..09d7d4079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added +- **One-shot `device.*` identification probe in MQTT push parser (#1684 enabler)** — Adding support for a new Bambu printer model needs the internal model code the firmware sends in MQTT `device.dev_model_name` (e.g. A1 is `N2S`, H2C is `O1C`, X2D is `N6`). The field arrives on every push but Bambuddy never logged it, so even a debug-enabled support bundle from a new-model user (A2L on #1684 was the case that surfaced this) gave us no way to identify the model — `get_version` was also missing because the printer disconnected right after the request topic subscription, which is a separate firmware quirk. **Fix:** at the top of the existing `device.*` parsing block in `bambu_mqtt.py`, emit one INFO log per client session dumping `dev_model_name` / `dev_product_name` / `dev_id` / `project_name` if any are present; otherwise fall back to `device.keys()` so a future Bambu rename (e.g. `model_name` without the `dev_` prefix) still surfaces. INFO level so the line lands in every support bundle, not just debug-enabled ones; one-shot via a `_device_id_logged` flag matching the existing `_nozzle_fields_logged` pattern at line 2095 — no spam at every push_status. 3 unit tests in `TestDeviceIdentificationProbe` pin the one-shot behaviour, the known-id-field path, and the keys-fallback path. Full `test_bambu_mqtt.py` suite 281 / 281 green; ruff clean. Once this ships, a new-model issue self-resolves from the first bundle — no second round of "please enable debug and reupload" required. + - **Archives page banner: reactive install-step-4 nudge for the slicer-side setting** — Companion to the new `external_storage` diagnostic check. The diagnostic catches the printer-side variant of "Store sent files on external storage" via `home_flag` bit 11. The slicer-side variant on older BambuStudio / OrcaSlicer never reaches the printer, so the diagnostic passes even when the option is off in the slicer. The deterministic symptom is the archiver creating a row with `extra_data.no_3mf_available=True` (`main.py:2770`) — that's the signal this banner watches. New backend endpoint `GET /archives/no-3mf-warning` returns `{has_fallback: bool}` — true iff any archive in the last 30 days has the flag set AND isn't soft-deleted. The 30-day window prevents old never-fixed installs from showing the banner forever; the soft-delete filter respects the user clearing the evidence. Frontend banner sits at the top of the Archives page (amber, dismissible) — "Some recent prints couldn't be archived with thumbnails…" + link to install step 4 in the wiki. Dismissal is one-shot via `localStorage` key `archiveNo3MFWarningDismissed` (matches the existing `Layout.tsx` update-banner pattern but persistent across sessions, since "you've been told" should outlive a browser restart). React-Query is `enabled: !dismissed` so the endpoint isn't polled after dismissal. 5 backend integration tests (`TestNo3MFWarning`) cover: recent fallback returns true, no archives returns false, archives without the flag returns false, >30-day-old fallbacks ignored, soft-deleted fallbacks ignored. i18n: 4 new keys (`title`, `body`, `docsLink`, `dismissLabel`) under `archives.no3mfBanner` translated to all 11 locales — no English fallbacks. - **Connection diagnostic now verifies install step 4 ("Store sent files on external storage")** — Many users miss this setting when adding their first printer; without it BambuStudio / OrcaSlicer never leave a `.gcode.3mf` on the printer's SD card, every archived print falls back to no-thumbnail / no-metadata, and the cause is invisible until the user notices the archive is empty. **The trap with detecting this**: on newer firmware (P2S 01.02 / Bambu Studio 2.6+) the toggle moved onto the printer itself and is pushed on MQTT `home_flag` bit 11 (Bambuddy already parses this into `state.store_to_sdcard`). On older versions it's a purely slicer-side preference invisible to the printer. An FTP upload-probe approach was tried first — it always passed regardless of the slicer toggle because the `/cache` directory is always writable from Bambuddy's perspective; the slicer toggle only controls what BambuStudio chooses to do, not what the printer accepts from other clients. Confirmed empirically against an X1C + H2D with the slicer option toggled off (probe still succeeded, `home_flag` bit 11 stayed True). **Fix**: new `external_storage` check reads `state.store_to_sdcard` directly. Pass when the printer reports the bit on, fail when off, skip when no live MQTT state or the field has never been populated (older firmware that doesn't push `home_flag`). Localised fix-text points at install step 4 with both the printer-side and slicer-side variants spelled out; the `skip` text explicitly calls out the older-slicer limitation so users on that path know to verify manually. Slot in the check list sits between `port_ftps` and `mqtt_auth`. 5 new tests (`TestExternalStorageCheck`) cover pass-on-true, fail-on-false, skip-on-disconnect, skip-on-pre-add (no state), skip-on-missing-field. The reactive symptom-side detection — a one-time banner the first time the archiver records `extra_data.no_3mf_available=True` after a slicer-initiated print — is planned as a separate follow-up to cover the slicer-only setting case. Wiki updated on the System page (`features/system-info.md`) and the Troubleshooting page (`reference/troubleshooting.md`). i18n: 4 new keys (title, pass, fail, skip) localised to all 11 locales (de, en, es, fr, it, ja, ko, pt-BR, tr, zh-CN, zh-TW) — no English fallbacks. diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index ba500b724..7da3a98ad 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -2100,6 +2100,26 @@ class BambuMQTTClient: # bit 8 = 1 → LEFT extruder (active_extruder=1) if "device" in data and isinstance(data.get("device"), dict): device = data["device"] + # One-shot identification probe: surface whatever the firmware uses to + # name itself so an unknown model in a support bundle becomes self- + # diagnosing. INFO level so it shows up without debug logging. Falls + # back to dumping device.keys() if none of the known fields are present + # (so a future Bambu rename like `model_name` is still observable). + if not getattr(self, "_device_id_logged", False): + id_fields = { + k: device.get(k) + for k in ("dev_model_name", "dev_product_name", "dev_id", "project_name") + if k in device + } + if id_fields: + logger.info("[%s] Device identification: %s", self.serial_number, id_fields) + else: + logger.info( + "[%s] Device identification: no known id fields; device.keys=%s", + self.serial_number, + sorted(device.keys()), + ) + self._device_id_logged = True if "extruder" in device and "state" in device["extruder"]: state_val = device["extruder"]["state"] # Extract bit 8 for extruder position diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 36e51ebd0..b3f77bf81 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -2752,6 +2752,67 @@ class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin): assert h2d_client.state.tray_now == 128 +# --------------------------------------------------------------------------- +# 8. Device identification probe (#1684 enabler) +# --------------------------------------------------------------------------- + + +class TestDeviceIdentificationProbe: + """One-shot INFO log of any device.* identification fields the firmware + sends. Lets a new-model support bundle self-disclose the internal model + code (e.g. dev_model_name='N2L') without a separate debug build. + """ + + @pytest.fixture + def mqtt_client(self): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + return BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST_PROBE", + access_code="12345678", + ) + + def _device_payload(self, device): + return {"print": {"device": device}} + + def test_logs_known_id_fields_once(self, mqtt_client, caplog): + import logging + + caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt") + mqtt_client._process_message( + self._device_payload({"dev_model_name": "N2S", "dev_product_name": "Bambu Lab A1"}) + ) + matches = [r for r in caplog.records if "Device identification" in r.getMessage()] + assert len(matches) == 1 + msg = matches[0].getMessage() + assert "dev_model_name" in msg and "N2S" in msg + assert "dev_product_name" in msg + + def test_one_shot_does_not_repeat(self, mqtt_client, caplog): + import logging + + caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt") + payload = self._device_payload({"dev_model_name": "N2S"}) + mqtt_client._process_message(payload) + mqtt_client._process_message(payload) + mqtt_client._process_message(payload) + matches = [r for r in caplog.records if "Device identification" in r.getMessage()] + assert len(matches) == 1 + + def test_fallback_dumps_keys_when_no_known_fields(self, mqtt_client, caplog): + """Future Bambu rename (e.g. model_name without dev_ prefix) still surfaces.""" + import logging + + caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt") + mqtt_client._process_message(self._device_payload({"model_name": "MysteryModel", "extruder": {"state": 0}})) + matches = [r for r in caplog.records if "Device identification" in r.getMessage()] + assert len(matches) == 1 + msg = matches[0].getMessage() + assert "no known id fields" in msg + assert "model_name" in msg and "extruder" in msg + + # --------------------------------------------------------------------------- # 8. H2D Full multi-message sequences # ---------------------------------------------------------------------------