From 42610f4e094954bc8a7e3fcad33f8e71b60708b4 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 18 Jul 2024 11:57:11 +0200 Subject: [PATCH] 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 --------- Co-authored-by: G Johansson --- homeassistant/components/dsmr/__init__.py | 42 ++++++---- homeassistant/components/dsmr/const.py | 2 - homeassistant/components/dsmr/diagnostics.py | 28 +++++++ homeassistant/components/dsmr/sensor.py | 8 +- .../dsmr/snapshots/test_diagnostics.ambr | 29 +++++++ tests/components/dsmr/test_config_flow.py | 3 +- tests/components/dsmr/test_diagnostics.py | 82 +++++++++++++++++++ 7 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/dsmr/diagnostics.py create mode 100644 tests/components/dsmr/snapshots/test_diagnostics.ambr create mode 100644 tests/components/dsmr/test_diagnostics.py diff --git a/homeassistant/components/dsmr/__init__.py b/homeassistant/components/dsmr/__init__.py index 00252b98517..e21262cf807 100644 --- a/homeassistant/components/dsmr/__init__.py +++ b/homeassistant/components/dsmr/__init__.py @@ -2,18 +2,32 @@ from __future__ import annotations -from asyncio import CancelledError +from asyncio import CancelledError, Task from contextlib import suppress +from dataclasses import dataclass from typing import Any +from dsmr_parser.objects import Telegram + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback 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.""" @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) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - + entry.runtime_data = DsmrState() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) 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.""" - task = hass.data[DOMAIN][entry.entry_id][DATA_TASK] # Cancel the reconnect task - task.cancel() - with suppress(CancelledError): - await task + if task := entry.runtime_data.task: + task.cancel() + with suppress(CancelledError): + await task - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, entry: DsmrConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index d40581bcdee..7f5813cda7f 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -23,8 +23,6 @@ DEFAULT_PRECISION = 3 DEFAULT_RECONNECT_INTERVAL = 30 DEFAULT_TIME_BETWEEN_UPDATE = 30 -DATA_TASK = "task" - DEVICE_NAME_ELECTRICITY = "Electricity Meter" DEVICE_NAME_GAS = "Gas Meter" DEVICE_NAME_WATER = "Water Meter" diff --git a/homeassistant/components/dsmr/diagnostics.py b/homeassistant/components/dsmr/diagnostics.py new file mode 100644 index 00000000000..6f3b76273e1 --- /dev/null +++ b/homeassistant/components/dsmr/diagnostics.py @@ -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, + } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index d46b2777a34..39b90f2060b 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -46,12 +46,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle +from . import DsmrConfigEntry from .const import ( CONF_DSMR_VERSION, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, - DATA_TASK, DEFAULT_PRECISION, DEFAULT_RECONNECT_INTERVAL, DEFAULT_TIME_BETWEEN_UPDATE, @@ -514,7 +514,7 @@ def create_mbus_entities( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: DsmrConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the DSMR sensor.""" dsmr_version = entry.data[CONF_DSMR_VERSION] @@ -567,6 +567,8 @@ async def async_setup_entry( for entity in entities: entity.update_data(telegram) + entry.runtime_data.telegram = telegram + if not initialized and telegram: initialized = True async_dispatcher_send( @@ -695,7 +697,7 @@ async def async_setup_entry( ) # 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): diff --git a/tests/components/dsmr/snapshots/test_diagnostics.ambr b/tests/components/dsmr/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ec2dc274efa --- /dev/null +++ b/tests/components/dsmr/snapshots/test_diagnostics.ambr @@ -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', + }), + }) +# --- diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 3b4dc533993..91adf38eacf 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -10,7 +10,8 @@ import serial import serial.tools.list_ports 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.data_entry_flow import FlowResultType diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py new file mode 100644 index 00000000000..8fc996f6e34 --- /dev/null +++ b/tests/components/dsmr/test_diagnostics.py @@ -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": "m³"}, + ], + ), + "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