diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 8159d40d2d5fc..e271aac4ee5c2 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -3,6 +3,7 @@ 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.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 @@ 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 + 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 + ] ) - and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data) - ] - async_add_entities(entities) + 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 @@ def close_transport(_event: Event) -> None: @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 @@ def available(self) -> bool: """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 @@ def native_value(self) -> StateType: 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.""" diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 6972f0cc0cf44..d734f0a93d57f 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -23,8 +23,6 @@ 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 wait_closed(): 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 wait_closed(): 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"