Enhanced MQTT support with separate topics and multipliers (#173)

- Add separate MQTT topics for power, energy, and state monitoring
  - mqtt_power_topic, mqtt_power_path, mqtt_power_multiplier
  - mqtt_energy_topic, mqtt_energy_path, mqtt_energy_multiplier
  - mqtt_state_topic, mqtt_state_path, mqtt_state_on_value
- Support different MQTT topics per data type (e.g., Zigbee2MQTT with
  separate power/energy/state topics)
- Individual multipliers for power and energy (e.g., mW→W, Wh→kWh)
- Configurable ON value for state monitoring (e.g., "ON", "true", "1")
- Maintain backward compatibility with legacy mqtt_topic/mqtt_multiplier
- Database migration auto-copies legacy fields to new fields
- Update backup/restore to handle new MQTT fields
- Add backend tests for new MQTT configurations
- Update frontend form with organized Power/Energy/State sections

Closes #173
This commit is contained in:
maziggy
2026-01-30 14:17:26 +01:00
parent 00e3478a3e
commit 04f4dccd41
15 changed files with 836 additions and 306 deletions
+11 -1
View File
@@ -315,9 +315,19 @@ def smart_plug_factory(db_session):
defaults["ha_entity_id"] = "switch.test"
defaults["ip_address"] = None
elif plug_type == "mqtt":
# Legacy fields (for backward compatibility tests)
defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic")
defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power")
defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0)
# New separate topic/path/multiplier fields
defaults["mqtt_power_topic"] = kwargs.get("mqtt_power_topic")
defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power")
defaults["mqtt_power_multiplier"] = kwargs.get("mqtt_power_multiplier", 1.0)
defaults["mqtt_energy_topic"] = kwargs.get("mqtt_energy_topic")
defaults["mqtt_energy_path"] = kwargs.get("mqtt_energy_path")
defaults["mqtt_energy_multiplier"] = kwargs.get("mqtt_energy_multiplier", 1.0)
defaults["mqtt_state_topic"] = kwargs.get("mqtt_state_topic")
defaults["mqtt_state_path"] = kwargs.get("mqtt_state_path")
defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value")
defaults["ip_address"] = None
defaults["ha_entity_id"] = None
else:
@@ -694,3 +694,132 @@ class TestSmartPlugsAPI:
result = response.json()
assert result["mqtt_topic"] == "new/topic"
assert result["mqtt_power_path"] == "new_power"
# ========================================================================
# Enhanced MQTT Integration tests (separate topics per data type)
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
"""Verify MQTT plug can be created with separate topics for power, energy, and state."""
data = {
"name": "MQTT Separate Topics",
"plug_type": "mqtt",
"mqtt_power_topic": "zigbee/power",
"mqtt_power_path": "power_l1",
"mqtt_power_multiplier": 0.001,
"mqtt_energy_topic": "zigbee/energy",
"mqtt_energy_path": "energy_total",
"mqtt_energy_multiplier": 1.0,
"mqtt_state_topic": "zigbee/state",
"mqtt_state_path": "state",
"mqtt_state_on_value": "ON",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "MQTT Separate Topics"
assert result["plug_type"] == "mqtt"
# Power fields
assert result["mqtt_power_topic"] == "zigbee/power"
assert result["mqtt_power_path"] == "power_l1"
assert result["mqtt_power_multiplier"] == 0.001
# Energy fields
assert result["mqtt_energy_topic"] == "zigbee/energy"
assert result["mqtt_energy_path"] == "energy_total"
assert result["mqtt_energy_multiplier"] == 1.0
# State fields
assert result["mqtt_state_topic"] == "zigbee/state"
assert result["mqtt_state_path"] == "state"
assert result["mqtt_state_on_value"] == "ON"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
"""Verify MQTT plug can be created with only energy monitoring."""
data = {
"name": "Energy Only Monitor",
"plug_type": "mqtt",
"mqtt_energy_topic": "sensors/energy",
"mqtt_energy_path": "kwh",
"mqtt_energy_multiplier": 0.001, # Wh to kWh
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["mqtt_energy_topic"] == "sensors/energy"
assert result["mqtt_energy_path"] == "kwh"
assert result["mqtt_energy_multiplier"] == 0.001
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
"""Verify MQTT plug can be created with only state monitoring."""
data = {
"name": "State Only Monitor",
"plug_type": "mqtt",
"mqtt_state_topic": "switches/outlet",
"mqtt_state_path": "state",
"mqtt_state_on_value": "true",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["mqtt_state_topic"] == "switches/outlet"
assert result["mqtt_state_path"] == "state"
assert result["mqtt_state_on_value"] == "true"
@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."""
data = {
"name": "Invalid MQTT Plug",
"plug_type": "mqtt",
# Has topic but no path - not a complete data source
"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
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_mqtt_plug_separate_multipliers(
self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service
):
"""Verify MQTT plug multipliers can be updated separately."""
plug = await smart_plug_factory(
plug_type="mqtt",
mqtt_power_topic="test/power",
mqtt_power_path="power",
mqtt_power_multiplier=1.0,
mqtt_energy_topic="test/energy",
mqtt_energy_path="energy",
mqtt_energy_multiplier=1.0,
)
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={
"mqtt_power_multiplier": 0.001, # Change power multiplier only
"mqtt_energy_multiplier": 0.001, # Change energy multiplier only
},
)
assert response.status_code == 200
result = response.json()
assert result["mqtt_power_multiplier"] == 0.001
assert result["mqtt_energy_multiplier"] == 0.001