Explorar el Código

fix(smart-plug): restore MQTT subscriptions for per-type topic configs on startup (#1010)

  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.
maziggy hace 1 mes
padre
commit
74527d4124

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.4b1] - Unreleased
 ## [0.2.4b1] - Unreleased
 
 
 ### Fixed
 ### 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.
 - **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 `<img>` 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 `<img>`/`<video>` pointing at `/api/v1/` without the current token, reloading them in place.
 - **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 `<img>` 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 `<img>`/`<video>` pointing at `/api/v1/` without the current token, reloading them in place.
 - **P2S Firmware Check Shows Stale "Latest" Version** ([#1030](https://github.com/maziggy/bambuddy/issues/1030)) — On P2S (and X2D) the Firmware Info modal reported `01.01.01.00` as the newest available release even though `01.02.00.00` had shipped on the Bambu Lab wiki weeks earlier, so the "update available" badge never appeared. Two silent regex mismatches in the wiki scraper caused `_fetch_all_versions_from_wiki()` to return an empty list: (1) the section-heading anchor parser required a dash between the version bytes and the release date (`id="h-01020000-20260409"`), but P2S and X2D publish anchors without the dash (`id="h-0102000020260409"`); (2) the text-based fallback only accepted ASCII parens around the date, while P2S, X2D, A1 and A1-mini headings render dates in full-width `(YYYYMMDD)` (U+FF08/U+FF09). When both paths failed, the code silently fell back to the Bambu Lab download page, which still lagged at `01.01.01.00`. The anchor regex now accepts an optional dash and the fallback accepts both paren styles; added regression tests for the no-dash anchor and full-width paren shapes. Thanks to @Minebuddy for reporting.
 - **P2S Firmware Check Shows Stale "Latest" Version** ([#1030](https://github.com/maziggy/bambuddy/issues/1030)) — On P2S (and X2D) the Firmware Info modal reported `01.01.01.00` as the newest available release even though `01.02.00.00` had shipped on the Bambu Lab wiki weeks earlier, so the "update available" badge never appeared. Two silent regex mismatches in the wiki scraper caused `_fetch_all_versions_from_wiki()` to return an empty list: (1) the section-heading anchor parser required a dash between the version bytes and the release date (`id="h-01020000-20260409"`), but P2S and X2D publish anchors without the dash (`id="h-0102000020260409"`); (2) the text-based fallback only accepted ASCII parens around the date, while P2S, X2D, A1 and A1-mini headings render dates in full-width `(YYYYMMDD)` (U+FF08/U+FF09). When both paths failed, the code silently fell back to the Bambu Lab download page, which still lagged at `01.01.01.00`. The anchor regex now accepts an optional dash and the fallback accepts both paren styles; added regression tests for the no-dash anchor and full-width paren shapes. Thanks to @Minebuddy for reporting.

+ 7 - 46
backend/app/api/routes/smart_plugs.py

@@ -33,6 +33,7 @@ from backend.app.schemas.smart_plug import (
 from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_relay import mqtt_relay
+from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
@@ -124,30 +125,9 @@ async def create_smart_plug(
 
 
     # Subscribe MQTT plugs to their topics
     # Subscribe MQTT plugs to their topics
     if plug.plug_type == "mqtt":
     if plug.plug_type == "mqtt":
-        # Determine effective topics (new fields take priority, fall back to legacy)
-        power_topic = plug.mqtt_power_topic or plug.mqtt_topic
-        energy_topic = plug.mqtt_energy_topic
-        state_topic = plug.mqtt_state_topic
-
-        # Only subscribe if at least one topic is configured
-        if power_topic or energy_topic or state_topic:
-            mqtt_relay.smart_plug_service.subscribe(
-                plug_id=plug.id,
-                # Power source (path is optional)
-                power_topic=power_topic,
-                power_path=plug.mqtt_power_path,
-                power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
-                # Energy source (path is optional)
-                energy_topic=energy_topic,
-                energy_path=plug.mqtt_energy_path,
-                energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
-                # State source (path is optional)
-                state_topic=state_topic,
-                state_path=plug.mqtt_state_path,
-                state_on_value=plug.mqtt_state_on_value,
-            )
-            topics = [t for t in [power_topic, energy_topic, state_topic] if t]
-            logger.info("Created MQTT plug '%s' subscribed to %s", plug.name, ", ".join(set(topics)))
+        topics = subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug)
+        if topics:
+            logger.info("Created MQTT plug '%s' subscribed to %s", plug.name, ", ".join(topics))
     elif plug.plug_type == "homeassistant":
     elif plug.plug_type == "homeassistant":
         logger.info("Created Home Assistant plug '%s' (%s)", plug.name, plug.ha_entity_id)
         logger.info("Created Home Assistant plug '%s' (%s)", plug.name, plug.ha_entity_id)
     else:
     else:
@@ -505,28 +485,9 @@ async def update_smart_plug(
             if old_plug_type == "mqtt":
             if old_plug_type == "mqtt":
                 mqtt_relay.smart_plug_service.unsubscribe(plug.id)
                 mqtt_relay.smart_plug_service.unsubscribe(plug.id)
 
 
-            # Subscribe to new topics
-            power_topic = plug.mqtt_power_topic or plug.mqtt_topic
-            energy_topic = plug.mqtt_energy_topic
-            state_topic = plug.mqtt_state_topic
-
-            # Only subscribe if at least one topic is configured
-            if power_topic or energy_topic or state_topic:
-                mqtt_relay.smart_plug_service.subscribe(
-                    plug_id=plug.id,
-                    # Power source (path is optional)
-                    power_topic=power_topic,
-                    power_path=plug.mqtt_power_path,
-                    power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
-                    # Energy source (path is optional)
-                    energy_topic=energy_topic,
-                    energy_path=plug.mqtt_energy_path,
-                    energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
-                    # State source (path is optional)
-                    state_topic=state_topic,
-                    state_path=plug.mqtt_state_path,
-                    state_on_value=plug.mqtt_state_on_value,
-                )
+            # Subscribe via the shared helper (matches startup restore and
+            # create route) — keeps all three paths in lock-step.
+            subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug)
 
 
     logger.info("Updated smart plug '%s'", plug.name)
     logger.info("Updated smart plug '%s'", plug.name)
     return plug
     return plug

+ 6 - 11
backend/app/main.py

@@ -4120,21 +4120,16 @@ async def lifespan(app: FastAPI):
         # Restore MQTT smart plug subscriptions
         # Restore MQTT smart plug subscriptions
         if mqtt_settings.get("mqtt_enabled"):
         if mqtt_settings.get("mqtt_enabled"):
             from backend.app.models.smart_plug import SmartPlug
             from backend.app.models.smart_plug import SmartPlug
+            from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 
 
             result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
             result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
             mqtt_plugs = result.scalars().all()
             mqtt_plugs = result.scalars().all()
+            restored = 0
             for plug in mqtt_plugs:
             for plug in mqtt_plugs:
-                if plug.mqtt_topic:
-                    mqtt_relay.smart_plug_service.subscribe(
-                        plug_id=plug.id,
-                        topic=plug.mqtt_topic,
-                        power_path=plug.mqtt_power_path,
-                        energy_path=plug.mqtt_energy_path,
-                        state_path=plug.mqtt_state_path,
-                        multiplier=plug.mqtt_multiplier or 1.0,
-                    )
-            if mqtt_plugs:
-                logging.info("Restored %s MQTT smart plug subscriptions", len(mqtt_plugs))
+                if subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug):
+                    restored += 1
+            if restored:
+                logging.info("Restored %s MQTT smart plug subscriptions", restored)
 
 
     # Connect to all active printers
     # Connect to all active printers
     async with async_session() as db:
     async with async_session() as db:

+ 35 - 0
backend/app/services/mqtt_smart_plug.py

@@ -490,5 +490,40 @@ class MQTTSmartPlugService:
                 self.connected = False
                 self.connected = False
 
 
 
 
+def subscribe_plug_to_mqtt(service: "MQTTSmartPlugService", plug: Any) -> list[str]:
+    """Resolve per-type topic fields on a SmartPlug and register it with the service.
+
+    The SmartPlug model carries both a legacy single `mqtt_topic` and newer
+    per-type `mqtt_{power,energy,state}_topic` fields. Three code paths used
+    to open-code this resolution (startup restore, create, update) and they
+    drifted — the startup path skipped plugs that only had per-type topics
+    set, leaving them unsubscribed after every restart (#1010). Funnelling
+    all three through this helper keeps them in sync.
+
+    Returns the list of topics subscribed (empty if nothing was configured).
+    """
+    power_topic = plug.mqtt_power_topic or plug.mqtt_topic
+    energy_topic = plug.mqtt_energy_topic or plug.mqtt_topic
+    state_topic = plug.mqtt_state_topic or plug.mqtt_topic
+
+    if not (power_topic or energy_topic or state_topic):
+        return []
+
+    legacy_mult = plug.mqtt_multiplier or 1.0
+    service.subscribe(
+        plug_id=plug.id,
+        power_topic=power_topic,
+        power_path=plug.mqtt_power_path,
+        power_multiplier=plug.mqtt_power_multiplier or legacy_mult,
+        energy_topic=energy_topic,
+        energy_path=plug.mqtt_energy_path,
+        energy_multiplier=plug.mqtt_energy_multiplier or legacy_mult,
+        state_topic=state_topic,
+        state_path=plug.mqtt_state_path,
+        state_on_value=plug.mqtt_state_on_value,
+    )
+    return [t for t in {power_topic, energy_topic, state_topic} if t]
+
+
 # Global instance
 # Global instance
 mqtt_smart_plug_service = MQTTSmartPlugService()
 mqtt_smart_plug_service = MQTTSmartPlugService()

+ 145 - 0
backend/tests/unit/services/test_mqtt_smart_plug_subscribe.py

@@ -0,0 +1,145 @@
+"""
+Tests for subscribe_plug_to_mqtt — the shared helper that resolves a
+SmartPlug row's per-type topic fields (with legacy fallback) and calls
+MQTTSmartPlugService.subscribe().
+
+Regression guard for #1010, where the startup-restore code path had
+drifted from the create/update routes: it only looked at the legacy
+`mqtt_topic` field and silently skipped plugs whose topics were set
+only in the newer per-type fields, so the MQTT smart-plug subscription
+was lost on every Bambuddy restart until the user re-saved the plug.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
+
+
+def _plug(**overrides):
+    """Build a SmartPlug-shaped record. All fields default to None/defaults."""
+    defaults = {
+        "id": 1,
+        "mqtt_topic": None,
+        "mqtt_power_topic": None,
+        "mqtt_power_path": None,
+        "mqtt_power_multiplier": None,
+        "mqtt_energy_topic": None,
+        "mqtt_energy_path": None,
+        "mqtt_energy_multiplier": None,
+        "mqtt_state_topic": None,
+        "mqtt_state_path": None,
+        "mqtt_state_on_value": None,
+        "mqtt_multiplier": None,
+    }
+    defaults.update(overrides)
+    return SimpleNamespace(**defaults)
+
+
+def test_per_type_topics_restored_without_legacy_mqtt_topic():
+    """#1010: plug configured only with per-type topics must still subscribe."""
+    service = MagicMock()
+    plug = _plug(
+        id=42,
+        mqtt_power_topic="shellies/plug-living/power",
+        mqtt_power_path="value",
+        mqtt_state_topic="shellies/plug-living/relay/0",
+        mqtt_state_on_value="on",
+    )
+
+    topics = subscribe_plug_to_mqtt(service, plug)
+
+    service.subscribe.assert_called_once()
+    kwargs = service.subscribe.call_args.kwargs
+    assert kwargs["plug_id"] == 42
+    assert kwargs["power_topic"] == "shellies/plug-living/power"
+    assert kwargs["power_path"] == "value"
+    assert kwargs["state_topic"] == "shellies/plug-living/relay/0"
+    assert kwargs["state_on_value"] == "on"
+    # energy wasn't configured, so no per-type topic
+    assert kwargs["energy_topic"] is None
+    assert set(topics) == {"shellies/plug-living/power", "shellies/plug-living/relay/0"}
+
+
+def test_legacy_single_topic_falls_back_for_all_data_types():
+    """Backward-compat: a plug with only the legacy mqtt_topic must still work."""
+    service = MagicMock()
+    plug = _plug(
+        id=7,
+        mqtt_topic="zigbee2mqtt/shelly-office",
+        mqtt_power_path="power",
+        mqtt_energy_path="energy",
+        mqtt_state_path="state",
+        mqtt_state_on_value="ON",
+        mqtt_multiplier=0.001,  # legacy
+    )
+
+    topics = subscribe_plug_to_mqtt(service, plug)
+
+    kwargs = service.subscribe.call_args.kwargs
+    assert kwargs["power_topic"] == "zigbee2mqtt/shelly-office"
+    assert kwargs["energy_topic"] == "zigbee2mqtt/shelly-office"
+    assert kwargs["state_topic"] == "zigbee2mqtt/shelly-office"
+    # Legacy multiplier flows through for both power and energy.
+    assert kwargs["power_multiplier"] == 0.001
+    assert kwargs["energy_multiplier"] == 0.001
+    assert topics == ["zigbee2mqtt/shelly-office"]
+
+
+def test_per_type_multipliers_override_legacy():
+    service = MagicMock()
+    plug = _plug(
+        mqtt_power_topic="t/power",
+        mqtt_power_multiplier=0.5,
+        mqtt_energy_topic="t/energy",
+        mqtt_energy_multiplier=0.25,
+        mqtt_multiplier=9.0,  # should be overridden by per-type values
+    )
+
+    subscribe_plug_to_mqtt(service, plug)
+
+    kwargs = service.subscribe.call_args.kwargs
+    assert kwargs["power_multiplier"] == 0.5
+    assert kwargs["energy_multiplier"] == 0.25
+
+
+def test_per_type_topics_beat_legacy_topic_when_both_set():
+    """If both legacy and per-type topic are set, per-type wins."""
+    service = MagicMock()
+    plug = _plug(
+        mqtt_topic="old/topic",
+        mqtt_power_topic="new/power",
+        mqtt_energy_topic="new/energy",
+    )
+
+    subscribe_plug_to_mqtt(service, plug)
+
+    kwargs = service.subscribe.call_args.kwargs
+    assert kwargs["power_topic"] == "new/power"
+    assert kwargs["energy_topic"] == "new/energy"
+    # state has no per-type topic set, so it falls back to legacy
+    assert kwargs["state_topic"] == "old/topic"
+
+
+def test_no_topics_configured_skips_subscribe():
+    """Nothing to subscribe to means the service is not touched."""
+    service = MagicMock()
+    plug = _plug(id=99)  # all fields None
+
+    topics = subscribe_plug_to_mqtt(service, plug)
+
+    service.subscribe.assert_not_called()
+    assert topics == []
+
+
+def test_returns_unique_topic_list_when_same_topic_used_for_multiple_types():
+    service = MagicMock()
+    plug = _plug(
+        mqtt_power_topic="shared/topic",
+        mqtt_energy_topic="shared/topic",
+        mqtt_state_topic="shared/topic",
+    )
+
+    topics = subscribe_plug_to_mqtt(service, plug)
+
+    assert topics == ["shared/topic"]