mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user