feat(mqtt): one-shot device identification probe for unknown models

Logs device.dev_model_name / dev_product_name / dev_id / project_name
  at INFO level once per client session, falling back to device.keys()
  if none of the known fields are present.

  The MQTT push_status carries the model code in device.dev_model_name
  on every message, but nothing in bambu_mqtt.py reads or logs that
  field — so adding a new printer model meant chasing the code through
  either Bambu cloud or a manual mosquitto_sub. A2L (#1684) was the
  case that surfaced this: get_version also failed because the firmware
  disconnected right after request topic subscription, so the support
  bundle had no way to disclose the model.

  INFO level so the line lands in support bundles without enabling
  debug. One-shot via _device_id_logged, mirroring the existing
  _nozzle_fields_logged flag at line 2095, so push_status spam is
  avoided.

  Future-proofs against Bambu renaming the field (the fallback dumps
  device.keys() so a rename like model_name without the dev_ prefix
  is still observable). 3 unit tests in TestDeviceIdentificationProbe
  pin all three branches.
This commit is contained in:
maziggy
2026-06-10 07:38:02 +02:00
parent 2bb69cb079
commit e01d3a7979
3 changed files with 83 additions and 0 deletions
+2
View File
@@ -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.
+20
View File
@@ -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
@@ -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
# ---------------------------------------------------------------------------