Fix suggested UOM cannot be set for dsmr entities (#102134)

* Supply dsmr entities jit on first telegram

* Stale docstr

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Simplify tuple type

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Jan Bouwhuis 2023-10-19 19:22:03 +02:00 committed by GitHub
parent c574cefc30
commit 9db9f1b8a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 185 additions and 67 deletions

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from asyncio import CancelledError
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import timedelta
@ -34,6 +35,10 @@ from homeassistant.const import (
)
from homeassistant.core import CoreState, Event, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import Throttle
@ -58,6 +63,8 @@ from .const import (
LOGGER,
)
EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}"
UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS}
@ -387,17 +394,58 @@ async def async_setup_entry(
) -> None:
"""Set up the DSMR sensor."""
dsmr_version = entry.data[CONF_DSMR_VERSION]
entities = [
DSMREntity(description, entry)
for description in SENSORS
if (
description.dsmr_versions is None
or dsmr_version in description.dsmr_versions
)
and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data)
]
async_add_entities(entities)
entities: list[DSMREntity] = []
initialized: bool = False
add_entities_handler: Callable[..., None] | None
@callback
def init_async_add_entities(telegram: dict[str, DSMRObject]) -> None:
"""Add the sensor entities after the first telegram was received."""
nonlocal add_entities_handler
assert add_entities_handler is not None
add_entities_handler()
add_entities_handler = None
def device_class_and_uom(
telegram: dict[str, DSMRObject],
entity_description: DSMRSensorEntityDescription,
) -> tuple[SensorDeviceClass | None, str | None]:
"""Get native unit of measurement from telegram,."""
dsmr_object = telegram[entity_description.obis_reference]
uom: str | None = getattr(dsmr_object, "unit") or None
with suppress(ValueError):
if entity_description.device_class == SensorDeviceClass.GAS and (
enery_uom := UnitOfEnergy(str(uom))
):
return (SensorDeviceClass.ENERGY, enery_uom)
if uom in UNIT_CONVERSION:
return (entity_description.device_class, UNIT_CONVERSION[uom])
return (entity_description.device_class, uom)
entities.extend(
[
DSMREntity(
description,
entry,
telegram,
*device_class_and_uom(
telegram, description
), # type: ignore[arg-type]
)
for description in SENSORS
if (
description.dsmr_versions is None
or dsmr_version in description.dsmr_versions
)
and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data)
and description.obis_reference in telegram
]
)
async_add_entities(entities)
add_entities_handler = async_dispatcher_connect(
hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), init_async_add_entities
)
min_time_between_updates = timedelta(
seconds=entry.options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE)
)
@ -405,10 +453,17 @@ async def async_setup_entry(
@Throttle(min_time_between_updates)
def update_entities_telegram(telegram: dict[str, DSMRObject] | None) -> None:
"""Update entities with latest telegram and trigger state update."""
nonlocal initialized
# Make all device entities aware of new telegram
for entity in entities:
entity.update_data(telegram)
if not initialized and telegram:
initialized = True
async_dispatcher_send(
hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), telegram
)
# Creates an asyncio.Protocol factory for reading DSMR telegrams from
# serial and calls update_entities_telegram to update entities on arrival
protocol = entry.data.get(CONF_PROTOCOL, DSMR_PROTOCOL)
@ -525,6 +580,8 @@ async def async_setup_entry(
@callback
async def _async_stop(_: Event) -> None:
if add_entities_handler is not None:
add_entities_handler()
task.cancel()
# Make sure task is cancelled on shutdown (or tests complete)
@ -544,12 +601,19 @@ class DSMREntity(SensorEntity):
_attr_should_poll = False
def __init__(
self, entity_description: DSMRSensorEntityDescription, entry: ConfigEntry
self,
entity_description: DSMRSensorEntityDescription,
entry: ConfigEntry,
telegram: dict[str, DSMRObject],
device_class: SensorDeviceClass,
native_unit_of_measurement: str | None,
) -> None:
"""Initialize entity."""
self.entity_description = entity_description
self._attr_device_class = device_class
self._attr_native_unit_of_measurement = native_unit_of_measurement
self._entry = entry
self.telegram: dict[str, DSMRObject] | None = {}
self.telegram: dict[str, DSMRObject] | None = telegram
device_serial = entry.data[CONF_SERIAL_ID]
device_name = DEVICE_NAME_ELECTRICITY
@ -593,21 +657,6 @@ class DSMREntity(SensorEntity):
"""Entity is only available if there is a telegram."""
return self.telegram is not None
@property
def device_class(self) -> SensorDeviceClass | None:
"""Return the device class of this entity."""
device_class = super().device_class
# Override device class for gas sensors providing energy units, like
# kWh, MWh, GJ, etc. In those cases, the class should be energy, not gas
with suppress(ValueError):
if device_class == SensorDeviceClass.GAS and UnitOfEnergy(
str(self.native_unit_of_measurement)
):
return SensorDeviceClass.ENERGY
return device_class
@property
def native_value(self) -> StateType:
"""Return the state of sensor, if available, translate if needed."""
@ -628,14 +677,6 @@ class DSMREntity(SensorEntity):
return value
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement of this entity, if any."""
unit_of_measurement = self.get_dsmr_object_attr("unit")
if unit_of_measurement in UNIT_CONVERSION:
return UNIT_CONVERSION[unit_of_measurement]
return unit_of_measurement
@staticmethod
def translate_tariff(value: str, dsmr_version: str) -> str | None:
"""Convert 2/1 to normal/low depending on DSMR version."""

View File

@ -23,8 +23,6 @@ from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
@ -84,6 +82,14 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No
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)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
registry = er.async_get(hass)
entry = registry.async_get("sensor.electricity_meter_power_consumption")
@ -94,11 +100,9 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No
assert entry
assert entry.unique_id == "5678_gas_meter_reading"
telegram_callback = connection_factory.call_args_list[0][0][2]
# make sure entities have been created and return 'unavailable' state
# make sure entities are initialized
power_consumption = hass.states.get("sensor.electricity_meter_power_consumption")
assert power_consumption.state == STATE_UNAVAILABLE
assert power_consumption.state == "0.0"
assert (
power_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER
)
@ -107,7 +111,24 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No
power_consumption.attributes.get(ATTR_STATE_CLASS)
== SensorStateClass.MEASUREMENT
)
assert power_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
assert power_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "W"
telegram = {
CURRENT_ELECTRICITY_USAGE: CosemObject(
CURRENT_ELECTRICITY_USAGE,
[{"value": Decimal("35.0"), "unit": UnitOfPower.WATT}],
),
ELECTRICITY_ACTIVE_TARIFF: CosemObject(
ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}]
),
GAS_METER_READING: MBusObject(
GAS_METER_READING,
[
{"value": datetime.datetime.fromtimestamp(1551642214)},
{"value": Decimal(745.701), "unit": UnitOfVolume.CUBIC_METERS},
],
),
}
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
@ -117,7 +138,7 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No
# ensure entities have new state value after incoming telegram
power_consumption = hass.states.get("sensor.electricity_meter_power_consumption")
assert power_consumption.state == "0.0"
assert power_consumption.state == "35.0"
assert power_consumption.attributes.get("unit_of_measurement") == UnitOfPower.WATT
# tariff should be translated in human readable and have no unit
@ -131,11 +152,11 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No
)
assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"]
assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ""
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
# check if gas consumption is parsed correctly
gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption")
assert gas_consumption.state == "745.695"
assert gas_consumption.state == "745.701"
assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS
assert (
gas_consumption.attributes.get(ATTR_FRIENDLY_NAME)
@ -153,6 +174,14 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No
async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) -> None:
"""Test the default setup."""
(connection_factory, transport, protocol) = dsmr_connection_fixture
from dsmr_parser.obis_references import (
CURRENT_ELECTRICITY_USAGE,
ELECTRICITY_ACTIVE_TARIFF,
)
from dsmr_parser.objects import CosemObject
entry_data = {
"port": "/dev/ttyUSB0",
"dsmr_version": "2.2",
@ -160,9 +189,22 @@ async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) -
"reconnect_interval": 30,
"serial_id": "1234",
}
entry_options = {
"time_between_update": 0,
}
telegram = {
CURRENT_ELECTRICITY_USAGE: CosemObject(
CURRENT_ELECTRICITY_USAGE,
[{"value": Decimal("35.0"), "unit": UnitOfPower.WATT}],
),
ELECTRICITY_ACTIVE_TARIFF: CosemObject(
ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}]
),
}
mock_entry = MockConfigEntry(
domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options
)
mock_entry.add_to_hass(hass)
@ -170,6 +212,14 @@ async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) -
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)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
registry = er.async_get(hass)
entry = registry.async_get("sensor.electricity_meter_power_consumption")
@ -229,8 +279,8 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None:
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
# after receiving telegram entities need to have the chance to update
await asyncio.sleep(0)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
# tariff should be translated in human readable and have no unit
active_tariff = hass.states.get("sensor.electricity_meter_active_tariff")
@ -239,7 +289,7 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None:
assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash"
assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"]
assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ""
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
# check if gas consumption is parsed correctly
gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption")
@ -308,8 +358,8 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None:
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
# after receiving telegram entities need to have the chance to update
await asyncio.sleep(0)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
# tariff should be translated in human readable and have no unit
active_tariff = hass.states.get("sensor.electricity_meter_active_tariff")
@ -318,7 +368,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None:
assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash"
assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"]
assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ""
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
# check if gas consumption is parsed correctly
gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption")
@ -389,8 +439,8 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) ->
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
# after receiving telegram entities need to have the chance to update
await asyncio.sleep(0)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total")
assert active_tariff.state == "123.456"
@ -472,8 +522,8 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
# after receiving telegram entities need to have the chance to update
await asyncio.sleep(0)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
# tariff should be translated in human readable and have no unit
active_tariff = hass.states.get("sensor.electricity_meter_active_tariff")
@ -482,7 +532,7 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No
assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash"
assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"]
assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ""
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
# check if gas consumption is parsed correctly
gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption")
@ -537,8 +587,8 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) -
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
# after receiving telegram entities need to have the chance to update
await asyncio.sleep(0)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
# tariff should be translated in human readable and have no unit
active_tariff = hass.states.get("sensor.electricity_meter_active_tariff")
@ -547,7 +597,7 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) -
assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash"
assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"]
assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ""
assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None:
@ -597,8 +647,8 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
# after receiving telegram entities need to have the chance to update
await asyncio.sleep(0)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total")
assert active_tariff.state == "123.456"
@ -675,8 +725,8 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None:
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
# after receiving telegram entities need to have the chance to update
await asyncio.sleep(0)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total")
assert active_tariff.state == "54184.6316"
@ -800,6 +850,12 @@ async def test_connection_errors_retry(
async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None:
"""If transport disconnects, the connection should be retried."""
from dsmr_parser.obis_references import (
CURRENT_ELECTRICITY_USAGE,
ELECTRICITY_ACTIVE_TARIFF,
)
from dsmr_parser.objects import CosemObject
(connection_factory, transport, protocol) = dsmr_connection_fixture
entry_data = {
@ -810,6 +866,19 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None:
"serial_id": "1234",
"serial_id_gas": "5678",
}
entry_options = {
"time_between_update": 0,
}
telegram = {
CURRENT_ELECTRICITY_USAGE: CosemObject(
CURRENT_ELECTRICITY_USAGE,
[{"value": Decimal("35.0"), "unit": UnitOfPower.WATT}],
),
ELECTRICITY_ACTIVE_TARIFF: CosemObject(
ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}]
),
}
# mock waiting coroutine while connection lasts
closed = asyncio.Event()
@ -823,7 +892,7 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None:
protocol.wait_closed = wait_closed
mock_entry = MockConfigEntry(
domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options
)
mock_entry.add_to_hass(hass)
@ -831,11 +900,19 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None:
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)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
assert connection_factory.call_count == 1
state = hass.states.get("sensor.electricity_meter_power_consumption")
assert state
assert state.state == STATE_UNKNOWN
assert state.state == "35.0"
# indicate disconnect, release wait lock and allow reconnect to happen
closed.set()
@ -897,7 +974,7 @@ async def test_gas_meter_providing_energy_reading(
telegram_callback = connection_factory.call_args_list[0][0][2]
telegram_callback(telegram)
await asyncio.sleep(0)
await hass.async_block_till_done()
gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption")
assert gas_consumption.state == "123.456"