Add diagnostic information to DSMR (#122041)

* Add diagnostic information to DSMR

Switches to runtime_data to get access
to the last telegram received.

* Correct import of domain

* Apply suggestions from code review

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
Joakim Plate 2024-07-18 11:57:11 +02:00 committed by GitHub
parent 41d75e159b
commit 42610f4e09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 171 additions and 23 deletions

View File

@ -2,18 +2,32 @@
from __future__ import annotations from __future__ import annotations
from asyncio import CancelledError from asyncio import CancelledError, Task
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass
from typing import Any from typing import Any
from dsmr_parser.objects import Telegram
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from .const import CONF_DSMR_VERSION, DATA_TASK, DOMAIN, PLATFORMS from .const import CONF_DSMR_VERSION, PLATFORMS
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @dataclass
class DsmrState:
"""State of integration."""
task: Task | None = None
telegram: Telegram | None = None
type DsmrConfigEntry = ConfigEntry[DsmrState]
async def async_setup_entry(hass: HomeAssistant, entry: DsmrConfigEntry) -> bool:
"""Set up DSMR from a config entry.""" """Set up DSMR from a config entry."""
@callback @callback
@ -25,32 +39,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await er.async_migrate_entries(hass, entry.entry_id, _async_migrate_entity_entry) await er.async_migrate_entries(hass, entry.entry_id, _async_migrate_entity_entry)
hass.data.setdefault(DOMAIN, {}) entry.runtime_data = DsmrState()
hass.data[DOMAIN][entry.entry_id] = {}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options)) entry.async_on_unload(entry.add_update_listener(async_update_options))
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: DsmrConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
task = hass.data[DOMAIN][entry.entry_id][DATA_TASK]
# Cancel the reconnect task # Cancel the reconnect task
task.cancel() if task := entry.runtime_data.task:
with suppress(CancelledError): task.cancel()
await task with suppress(CancelledError):
await task
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_update_options(hass: HomeAssistant, entry: DsmrConfigEntry) -> None:
"""Update options.""" """Update options."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)

View File

@ -23,8 +23,6 @@ DEFAULT_PRECISION = 3
DEFAULT_RECONNECT_INTERVAL = 30 DEFAULT_RECONNECT_INTERVAL = 30
DEFAULT_TIME_BETWEEN_UPDATE = 30 DEFAULT_TIME_BETWEEN_UPDATE = 30
DATA_TASK = "task"
DEVICE_NAME_ELECTRICITY = "Electricity Meter" DEVICE_NAME_ELECTRICITY = "Electricity Meter"
DEVICE_NAME_GAS = "Gas Meter" DEVICE_NAME_GAS = "Gas Meter"
DEVICE_NAME_WATER = "Water Meter" DEVICE_NAME_WATER = "Water Meter"

View File

@ -0,0 +1,28 @@
"""Diagnostics support for DSMR."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.util.json import json_loads
from . import DsmrConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: DsmrConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry": {
"data": {
**config_entry.data,
},
"unique_id": config_entry.unique_id,
},
"data": json_loads(config_entry.runtime_data.telegram.to_json())
if config_entry.runtime_data.telegram
else None,
}

View File

@ -46,12 +46,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.util import Throttle from homeassistant.util import Throttle
from . import DsmrConfigEntry
from .const import ( from .const import (
CONF_DSMR_VERSION, CONF_DSMR_VERSION,
CONF_SERIAL_ID, CONF_SERIAL_ID,
CONF_SERIAL_ID_GAS, CONF_SERIAL_ID_GAS,
CONF_TIME_BETWEEN_UPDATE, CONF_TIME_BETWEEN_UPDATE,
DATA_TASK,
DEFAULT_PRECISION, DEFAULT_PRECISION,
DEFAULT_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL,
DEFAULT_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE,
@ -514,7 +514,7 @@ def create_mbus_entities(
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: DsmrConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the DSMR sensor.""" """Set up the DSMR sensor."""
dsmr_version = entry.data[CONF_DSMR_VERSION] dsmr_version = entry.data[CONF_DSMR_VERSION]
@ -567,6 +567,8 @@ async def async_setup_entry(
for entity in entities: for entity in entities:
entity.update_data(telegram) entity.update_data(telegram)
entry.runtime_data.telegram = telegram
if not initialized and telegram: if not initialized and telegram:
initialized = True initialized = True
async_dispatcher_send( async_dispatcher_send(
@ -695,7 +697,7 @@ async def async_setup_entry(
) )
# Save the task to be able to cancel it when unloading # Save the task to be able to cancel it when unloading
hass.data[DOMAIN][entry.entry_id][DATA_TASK] = task entry.runtime_data.task = task
class DSMREntity(SensorEntity): class DSMREntity(SensorEntity):

View File

@ -0,0 +1,29 @@
# serializer version: 1
# name: test_diagnostics
dict({
'data': dict({
'CURRENT_ELECTRICITY_USAGE': dict({
'unit': 'W',
'value': 0.0,
}),
'ELECTRICITY_ACTIVE_TARIFF': dict({
'unit': '',
'value': '0001',
}),
'GAS_METER_READING': dict({
'datetime': '2019-03-03T19:43:33+00:00',
'unit': 'm³',
'value': 745.695,
}),
}),
'entry': dict({
'data': dict({
'dsmr_version': '2.2',
'port': '/dev/ttyUSB0',
'serial_id': '1234',
'serial_id_gas': '5678',
}),
'unique_id': '/dev/ttyUSB0',
}),
})
# ---

View File

@ -10,7 +10,8 @@ import serial
import serial.tools.list_ports import serial.tools.list_ports
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.dsmr import DOMAIN, config_flow from homeassistant.components.dsmr import config_flow
from homeassistant.components.dsmr.const import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType

View File

@ -0,0 +1,82 @@
"""Test DSMR diagnostics."""
import datetime
from decimal import Decimal
from unittest.mock import MagicMock
from dsmr_parser.obis_references import (
CURRENT_ELECTRICITY_USAGE,
ELECTRICITY_ACTIVE_TARIFF,
GAS_METER_READING,
)
from dsmr_parser.objects import CosemObject, MBusObject, Telegram
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
(connection_factory, transport, protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
"dsmr_version": "2.2",
"serial_id": "1234",
"serial_id_gas": "5678",
}
entry_options = {
"time_between_update": 0,
}
telegram = Telegram()
telegram.add(
CURRENT_ELECTRICITY_USAGE,
CosemObject(
(0, 0),
[{"value": Decimal("0.0"), "unit": "W"}],
),
"CURRENT_ELECTRICITY_USAGE",
)
telegram.add(
ELECTRICITY_ACTIVE_TARIFF,
CosemObject((0, 0), [{"value": "0001", "unit": ""}]),
"ELECTRICITY_ACTIVE_TARIFF",
)
telegram.add(
GAS_METER_READING,
MBusObject(
(0, 0),
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
{"value": Decimal(745.695), "unit": ""},
],
),
"GAS_METER_READING",
)
mock_entry = MockConfigEntry(
domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options
)
mock_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
telegram_callback = connection_factory.call_args_list[0][0][2]
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
result = await get_diagnostics_for_config_entry(hass, hass_client, mock_entry)
assert result == snapshot