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
This commit is contained in:
maziggy
2026-01-30 17:18:22 +01:00
parent 04f4dccd41
commit 140087eeb7
7 changed files with 68 additions and 75 deletions
+21 -36
View File
@@ -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
energy_topic = plug.mqtt_energy_topic
state_topic = plug.mqtt_state_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)
):
# 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
energy_topic = plug.mqtt_energy_topic
state_topic = plug.mqtt_state_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)
):
# 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
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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>
</>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>