From 74527d4124cb4b8794a4c8548b853371b3ac81fa Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 19 Apr 2026 13:51:58 +0200 Subject: [PATCH] fix(smart-plug): restore MQTT subscriptions for per-type topic configs on startup (#1010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users integrating a Shelly plug through an external MQTT broker (ioBroker, Zigbee2MQTT, HA's MQTT broker, etc.) lost the plug's power/state/energy readings after every Bambuddy restart. The only fix was opening Settings → Smart Plugs, renaming the topic to a dummy value, saving, renaming back, and saving again. Root cause: three code paths configure an MQTT smart plug's subscriptions — the startup restore in main.py, the create route, and the update route — and they had drifted. The create/update routes used the newer per-type model (mqtt_power_topic / mqtt_energy_topic / mqtt_state_topic with per-type paths, multipliers and mqtt_state_on_value) while the startup restore was still on the legacy single-topic model. Worse, the restore loop short-circuited on `if plug.mqtt_topic:`, skipping any plug whose topics were only set in the new per-type fields — exactly the shape of a Shelly-via-ioBroker config, which publishes power and state on separate topics. The "rename, save, rename back" workaround routed through the update endpoint and re-established the subscription the correct way. Extracted the topic-resolution + service.subscribe() call into subscribe_plug_to_mqtt() in mqtt_smart_plug.py and routed all three paths through it so the schema can't drift again. The helper keeps the legacy `mqtt_topic` field working as a fallback for all three data types — matching the behaviour the startup restore used to have via subscribe()'s internal `effective_*_topic or topic` collapsing, and matching the change-detection dict already used during updates. Regression tests cover: per-type topics restored without a legacy topic, legacy single-topic backward compat, per-type multipliers overriding legacy, per-type winning when both are set, the empty-config skip case, and topic-list de-duplication. --- CHANGELOG.md | 1 + backend/app/api/routes/smart_plugs.py | 53 +------ backend/app/main.py | 17 +- backend/app/services/mqtt_smart_plug.py | 35 +++++ .../test_mqtt_smart_plug_subscribe.py | 145 ++++++++++++++++++ 5 files changed, 194 insertions(+), 57 deletions(-) create mode 100644 backend/tests/unit/services/test_mqtt_smart_plug_subscribe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a48e84d..022c0383c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4b1] - Unreleased ### Fixed +- **MQTT Smart Plug Subscription Lost After Every Restart** ([#1010](https://github.com/maziggy/bambuddy/issues/1010)) — Users integrating a Shelly (or any other) plug through an external MQTT broker (e.g. ioBroker, Zigbee2MQTT, Home Assistant's MQTT broker) saw the plug's power / state / energy readings go dark after every Bambuddy restart, and the only fix was to open Settings → Smart Plugs, rename the topic to a dummy value, save, rename it back and save again. Root cause: the startup restore path in `main.py` (~line 4120) still used the legacy single-topic model (`mqtt_topic` plus `*_path` kwargs), while the Settings UI save path had been upgraded to the newer per-type model (`mqtt_power_topic` / `mqtt_energy_topic` / `mqtt_state_topic` each with their own paths, multipliers and `mqtt_state_on_value`). Plugs configured entirely with the new per-type fields got skipped at startup because the `if plug.mqtt_topic:` guard short-circuited — which is exactly what a Shelly-via-ioBroker setup looks like, since those publish power and state on separate topics. The "rename, save, rename back" workaround triggered the update endpoint, which was using the correct per-type code and re-established the subscription. Fix: extracted the topic-resolution + `service.subscribe()` call into a single `subscribe_plug_to_mqtt(service, plug)` helper in `backend/app/services/mqtt_smart_plug.py` that preserves legacy fallback, and routed the startup restore, create, and update routes all through it so future schema changes can't cause the three paths to drift again. Regression tests cover: per-type topics restored without a legacy topic set, legacy single-topic backward compat, per-type multipliers overriding legacy, per-type winning when both are set, the empty-config skip case, and topic-list de-duplication. Thanks to @saint-hh for the clear repro steps. - **Large 3MF Uploads Archived as Corrupted ZIPs** ([#1032](https://github.com/maziggy/bambuddy/issues/1032)) — On bare-metal Raspberry Pi installs (armv7l / Python 3.11 / Bookworm), 3MF files larger than a few MB arrived complete via the virtual-printer FTP server but the copy into `data/archives/` ended up not being a valid ZIP. The archive row was still written, the printer card looked fine, and the problem only surfaced later when opening the archive in the UI, where `GET /archives/{id}/plates` logged `Failed to parse plates from archive N: File is not a zip file` and the thumbnail / plate / filament panels came up blank. Two things conspired: `shutil.copy2` takes the Linux `sendfile()` fast path on Python ≥ 3.8, and a partial-return from that syscall silently truncated the destination for the upload sizes users hit; and `ThreeMFParser.parse()` had a bare `except: pass` around its `zipfile.ZipFile` open, so the archive pipeline kept going with empty metadata and left the bad file on disk. The copy is now an explicit chunked read/write with `fsync()` — no sendfile involved — with a post-condition `zipfile.is_zipfile()` check that refuses to create the archive row (and cleans up the archive directory) when the source was a valid ZIP and the destination isn't, logging both sizes at `ERROR`. The parser's silent catch now logs at `WARNING` so corrupted 3MFs are visible in support bundles instead of disappearing into empty metadata. Regression tests cover small / multi-chunk copies, ZIP roundtrips, the post-copy `is_zipfile` sentinel on a truncated file, and the new parser WARNING. Thanks to @saint-hh for the detailed diagnosis. - **Thumbnails Blank Until Reload After Sign-In** — On auth-enabled instances, signing out and back in left the File Manager (and occasionally the Archives page) full of broken thumbnails until the page was manually reloaded. Thumbnail URLs are gated by a short-lived camera-stream token that `` tags can't send via `Authorization` headers, so the token is appended as `?token=…` at render time. Two race conditions conspired to break this: (1) the token query was keyed only on `['camera-stream-token']` and fired while the user was still on the login page, 401'd, and stayed cached — after sign-in nothing invalidated it; (2) when the token did eventually arrive, the global variable holding it was not reactive, so any File Manager / Archives page that had already rendered kept serving image URLs with no token. The token query now includes the user id in its key and is gated on `!!user`, so a new login always triggers a fresh fetch; and when the token transitions from null to a value, `useStreamTokenSync` walks the DOM once and updates `src` on every already-rendered ``/`