Prechádzať zdrojové kódy

Make MQTT JSON path optional for smart plug monitoring (#173)
- Path is now optional for power, energy, and state topics
- When path is empty, raw MQTT payload value is used directly
- Energy and state topics no longer fall back to power topic
- Added helper text in UI explaining path is optional
- Fixes energy monitoring not working with separate topics

Closes 173

maziggy 3 mesiacov pred
rodič
commit
140087eeb7

+ 23 - 38
backend/app/api/routes/smart_plugs.py

@@ -100,39 +100,27 @@ async def create_smart_plug(
     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 or plug.mqtt_topic
-        state_topic = plug.mqtt_state_topic or plug.mqtt_topic
-
-        # Only subscribe if at least one data source is configured
-        if (
-            (power_topic and plug.mqtt_power_path)
-            or (energy_topic and plug.mqtt_energy_path)
-            or (state_topic and plug.mqtt_state_path)
-        ):
+        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
-                power_topic=power_topic if plug.mqtt_power_path else None,
+                # 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
-                energy_topic=energy_topic if plug.mqtt_energy_path else None,
+                # 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
-                state_topic=state_topic if plug.mqtt_state_path else None,
+                # 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 if plug.mqtt_power_path else None,
-                    energy_topic if plug.mqtt_energy_path else None,
-                    state_topic if plug.mqtt_state_path else None,
-                ]
-                if t
-            ]
+            topics = [t for t in [power_topic, energy_topic, state_topic] if t]
             logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}")
     elif plug.plug_type == "homeassistant":
         logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
@@ -412,26 +400,23 @@ async def update_smart_plug(
 
             # Subscribe to new topics
             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 (
-                (power_topic and plug.mqtt_power_path)
-                or (energy_topic and plug.mqtt_energy_path)
-                or (state_topic and plug.mqtt_state_path)
-            ):
+            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
-                    power_topic=power_topic if plug.mqtt_power_path else None,
+                    # 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
-                    energy_topic=energy_topic if plug.mqtt_energy_path else None,
+                    # 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
-                    state_topic=state_topic if plug.mqtt_state_path else None,
+                    # State source (path is optional)
+                    state_topic=state_topic,
                     state_path=plug.mqtt_state_path,
                     state_on_value=plug.mqtt_state_on_value,
                 )

+ 6 - 8
backend/app/schemas/smart_plug.py

@@ -71,16 +71,14 @@ class SmartPlugBase(BaseModel):
         if self.plug_type == "mqtt":
             # Determine the effective power topic (new field takes priority, fall back to legacy)
             power_topic = self.mqtt_power_topic or self.mqtt_topic
-            has_power = power_topic and self.mqtt_power_path
-            has_energy = self.mqtt_energy_topic and self.mqtt_energy_path
-            has_state = self.mqtt_state_topic and self.mqtt_state_path
+            # Path is optional - if not set, raw MQTT payload value will be used
+            has_power = bool(power_topic)
+            has_energy = bool(self.mqtt_energy_topic)
+            has_state = bool(self.mqtt_state_topic)
 
-            # At least one data source must be fully configured
+            # At least one data source must be configured (path is optional)
             if not has_power and not has_energy and not has_state:
-                raise ValueError(
-                    "At least one MQTT data source must be configured: "
-                    "power (topic + path), energy (topic + path), or state (topic + path)"
-                )
+                raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
         return self
 
 

+ 17 - 12
backend/app/services/mqtt_smart_plug.py

@@ -235,12 +235,17 @@ class MQTTSmartPlugService:
                 if not config:
                     continue
 
-                # Extract value using path (or use raw payload if no path or not JSON)
+                # Extract value using path (or use raw payload if no path)
                 if is_json and config.path:
                     raw_value = self._extract_json_path(payload, config.path)
                 elif is_json and not config.path:
-                    # JSON but no path - use the whole payload (shouldn't happen normally)
-                    raw_value = payload
+                    # JSON but no path - if it's a simple value use it, otherwise skip
+                    if isinstance(payload, (int, float, str, bool)):
+                        raw_value = payload
+                    else:
+                        # Can't use a dict/list as a value
+                        logger.debug(f"MQTT plug {plug_id}: JSON payload is object/array but no path configured")
+                        continue
                 else:
                     # Raw value (non-JSON)
                     raw_value = payload
@@ -361,31 +366,31 @@ class MQTTSmartPlugService:
             effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier
             effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier
 
-            # Configure power subscription
-            if effective_power_topic and power_path:
+            # Configure power subscription (path is optional - empty means use raw payload)
+            if effective_power_topic:
                 config = MQTTDataSourceConfig(
                     topic=effective_power_topic,
-                    path=power_path,
+                    path=power_path or "",
                     multiplier=effective_power_mult,
                 )
                 self.plug_configs[plug_id]["power"] = config
                 self._add_subscription(plug_id, effective_power_topic, "power")
 
-            # Configure energy subscription
-            if effective_energy_topic and energy_path:
+            # Configure energy subscription (path is optional - empty means use raw payload)
+            if effective_energy_topic:
                 config = MQTTDataSourceConfig(
                     topic=effective_energy_topic,
-                    path=energy_path,
+                    path=energy_path or "",
                     multiplier=effective_energy_mult,
                 )
                 self.plug_configs[plug_id]["energy"] = config
                 self._add_subscription(plug_id, effective_energy_topic, "energy")
 
-            # Configure state subscription
-            if effective_state_topic and state_path:
+            # Configure state subscription (path is optional - empty means use raw payload)
+            if effective_state_topic:
                 config = MQTTDataSourceConfig(
                     topic=effective_state_topic,
-                    path=state_path,
+                    path=state_path or "",
                     on_value=state_on_value,
                 )
                 self.plug_configs[plug_id]["state"] = config

+ 11 - 9
backend/tests/integration/test_smart_plugs_api.py

@@ -624,13 +624,12 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_mqtt_plug_missing_paths(self, async_client: AsyncClient):
-        """Verify creating MQTT plug without any JSON paths fails."""
+    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without any topic fails."""
         data = {
             "name": "MQTT Plug",
             "plug_type": "mqtt",
-            "mqtt_topic": "test/topic",
-            # Missing both mqtt_power_path and mqtt_state_path
+            # No topic configured at all
             "enabled": True,
         }
 
@@ -781,19 +780,22 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_mqtt_plug_no_data_source_fails(self, async_client: AsyncClient):
-        """Verify creating MQTT plug without any complete data source fails."""
+    async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify creating MQTT plug with topic only (no path) succeeds for raw values."""
         data = {
-            "name": "Invalid MQTT Plug",
+            "name": "Raw MQTT Plug",
             "plug_type": "mqtt",
-            # Has topic but no path - not a complete data source
+            # Topic only, no path - valid for raw numeric MQTT values
             "mqtt_power_topic": "zigbee/power",
             "enabled": True,
         }
 
         response = await async_client.post("/api/v1/smart-plugs/", json=data)
 
-        assert response.status_code == 422  # Validation error
+        assert response.status_code == 200  # Should succeed
+        result = response.json()
+        assert result["mqtt_power_topic"] == "zigbee/power"
+        assert result["mqtt_power_path"] is None
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 11 - 8
frontend/src/components/AddSmartPlugModal.tsx

@@ -296,13 +296,13 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     }
 
     if (plugType === 'mqtt') {
-      // Check that at least one data source is fully configured
-      const hasPower = mqttPowerTopic.trim() && mqttPowerPath.trim();
-      const hasEnergy = mqttEnergyTopic.trim() && mqttEnergyPath.trim();
-      const hasState = mqttStateTopic.trim() && mqttStatePath.trim();
+      // Check that at least one topic is configured (path is optional)
+      const hasPower = mqttPowerTopic.trim();
+      const hasEnergy = mqttEnergyTopic.trim();
+      const hasState = mqttStateTopic.trim();
 
       if (!hasPower && !hasEnergy && !hasState) {
-        setError('At least one data source must be configured: power (topic + path), energy (topic + path), or state (topic + path)');
+        setError('At least one MQTT topic must be configured for power, energy, or state monitoring');
         return;
       }
     }
@@ -971,7 +971,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                       </div>
                     </div>
                     <p className="text-xs text-bambu-gray">
-                      Use multiplier 0.001 for mW→W, 1000 for kW→W
+                      JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.<br/>
+                      Use multiplier 0.001 for mW→W, 1000 for kW→W.
                     </p>
                   </div>
 
@@ -1011,7 +1012,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                       </div>
                     </div>
                     <p className="text-xs text-bambu-gray">
-                      Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh
+                      JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
+                      Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.
                     </p>
                   </div>
 
@@ -1051,7 +1053,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                       </div>
                     </div>
                     <p className="text-xs text-bambu-gray">
-                      ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1)
+                      JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
+                      ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1).
                     </p>
                   </div>
                 </>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/index-C7_2Jo_p.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Cry_KMOA.js"></script>
+    <script type="module" crossorigin src="/assets/index-C7_2Jo_p.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Cs7zD_Fu.css">
   </head>
   <body>

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov