core/homeassistant/components/dsmr/sensor.py

825 lines
30 KiB
Python

"""Support for Dutch Smart Meter (also known as Smartmeter or P1 port)."""
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
from functools import partial
from dsmr_parser import obis_references
from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader
from dsmr_parser.clients.rfxtrx_protocol import (
create_rfxtrx_dsmr_reader,
create_rfxtrx_tcp_dsmr_reader,
)
from dsmr_parser.objects import DSMRObject
import serial
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PORT,
CONF_PROTOCOL,
EVENT_HOMEASSISTANT_STOP,
EntityCategory,
UnitOfEnergy,
UnitOfVolume,
)
from homeassistant.core import CoreState, Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
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
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,
DEVICE_NAME_ELECTRICITY,
DEVICE_NAME_GAS,
DEVICE_NAME_WATER,
DOMAIN,
DSMR_PROTOCOL,
LOGGER,
)
EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}"
UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS}
@dataclass(frozen=True, kw_only=True)
class DSMRSensorEntityDescription(SensorEntityDescription):
"""Represents an DSMR Sensor."""
dsmr_versions: set[str] | None = None
is_gas: bool = False
is_water: bool = False
obis_reference: str
SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="timestamp",
obis_reference=obis_references.P1_MESSAGE_TIMESTAMP,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
DSMRSensorEntityDescription(
key="current_electricity_usage",
translation_key="current_electricity_usage",
obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="current_electricity_delivery",
translation_key="current_electricity_delivery",
obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="electricity_active_tariff",
translation_key="electricity_active_tariff",
obis_reference=obis_references.ELECTRICITY_ACTIVE_TARIFF,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENUM,
options=["low", "normal"],
icon="mdi:flash",
),
DSMRSensorEntityDescription(
key="electricity_used_tariff_1",
translation_key="electricity_used_tariff_1",
obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
DSMRSensorEntityDescription(
key="electricity_used_tariff_2",
translation_key="electricity_used_tariff_2",
obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
DSMRSensorEntityDescription(
key="electricity_delivered_tariff_1",
translation_key="electricity_delivered_tariff_1",
obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
DSMRSensorEntityDescription(
key="electricity_delivered_tariff_2",
translation_key="electricity_delivered_tariff_2",
obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l1_positive",
translation_key="instantaneous_active_power_l1_positive",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l2_positive",
translation_key="instantaneous_active_power_l2_positive",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l3_positive",
translation_key="instantaneous_active_power_l3_positive",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l1_negative",
translation_key="instantaneous_active_power_l1_negative",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l2_negative",
translation_key="instantaneous_active_power_l2_negative",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l3_negative",
translation_key="instantaneous_active_power_l3_negative",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="short_power_failure_count",
translation_key="short_power_failure_count",
obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT,
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
icon="mdi:flash-off",
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="long_power_failure_count",
translation_key="long_power_failure_count",
obis_reference=obis_references.LONG_POWER_FAILURE_COUNT,
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
icon="mdi:flash-off",
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="voltage_sag_l1_count",
translation_key="voltage_sag_l1_count",
obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT,
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="voltage_sag_l2_count",
translation_key="voltage_sag_l2_count",
obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT,
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="voltage_sag_l3_count",
translation_key="voltage_sag_l3_count",
obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT,
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="voltage_swell_l1_count",
translation_key="voltage_swell_l1_count",
obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT,
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
icon="mdi:pulse",
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="voltage_swell_l2_count",
translation_key="voltage_swell_l2_count",
obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT,
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
icon="mdi:pulse",
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="voltage_swell_l3_count",
translation_key="voltage_swell_l3_count",
obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT,
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
icon="mdi:pulse",
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="instantaneous_voltage_l1",
translation_key="instantaneous_voltage_l1",
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L1,
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="instantaneous_voltage_l2",
translation_key="instantaneous_voltage_l2",
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L2,
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="instantaneous_voltage_l3",
translation_key="instantaneous_voltage_l3",
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L3,
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="instantaneous_current_l1",
translation_key="instantaneous_current_l1",
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L1,
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="instantaneous_current_l2",
translation_key="instantaneous_current_l2",
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L2,
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="instantaneous_current_l3",
translation_key="instantaneous_current_l3",
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L3,
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="belgium_max_power_per_phase",
translation_key="max_power_per_phase",
obis_reference=obis_references.BELGIUM_MAX_POWER_PER_PHASE,
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="belgium_max_current_per_phase",
translation_key="max_current_per_phase",
obis_reference=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE,
dsmr_versions={"5B"},
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="electricity_imported_total",
translation_key="electricity_imported_total",
obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL,
dsmr_versions={"5L", "5S", "Q3D"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
DSMRSensorEntityDescription(
key="electricity_exported_total",
translation_key="electricity_exported_total",
obis_reference=obis_references.ELECTRICITY_EXPORTED_TOTAL,
dsmr_versions={"5L", "5S", "Q3D"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
DSMRSensorEntityDescription(
key="belgium_current_average_demand",
translation_key="current_average_demand",
obis_reference=obis_references.BELGIUM_CURRENT_AVERAGE_DEMAND,
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
),
DSMRSensorEntityDescription(
key="belgium_maximum_demand_current_month",
translation_key="maximum_demand_current_month",
obis_reference=obis_references.BELGIUM_MAXIMUM_DEMAND_MONTH,
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
),
DSMRSensorEntityDescription(
key="hourly_gas_meter_reading",
translation_key="gas_meter_reading",
obis_reference=obis_references.HOURLY_GAS_METER_READING,
dsmr_versions={"4", "5", "5L"},
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
DSMRSensorEntityDescription(
key="gas_meter_reading",
translation_key="gas_meter_reading",
obis_reference=obis_references.GAS_METER_READING,
dsmr_versions={"2.2"},
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)
def create_mbus_entity(
mbus: int, mtype: int, telegram: dict[str, DSMRObject]
) -> DSMRSensorEntityDescription | None:
"""Create a new MBUS Entity."""
if (
mtype == 3
and (
obis_reference := getattr(
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2"
)
)
in telegram
):
return DSMRSensorEntityDescription(
key=f"mbus{mbus}_gas_reading",
translation_key="gas_meter_reading",
obis_reference=obis_reference,
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
)
if (
mtype == 7
and (
obis_reference := getattr(
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING1"
)
)
in telegram
):
return DSMRSensorEntityDescription(
key=f"mbus{mbus}_water_reading",
translation_key="water_meter_reading",
obis_reference=obis_reference,
is_water=True,
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
)
return 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)
def rename_old_gas_to_mbus(
hass: HomeAssistant, entry: ConfigEntry, mbus_device_id: str
) -> None:
"""Rename old gas sensor to mbus variant."""
dev_reg = dr.async_get(hass)
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
if device_entry_v1 is not None:
device_id = device_entry_v1.id
ent_reg = er.async_get(hass)
entries = er.async_entries_for_device(ent_reg, device_id)
for entity in entries:
if entity.unique_id.endswith("belgium_5min_gas_meter_reading"):
try:
ent_reg.async_update_entity(
entity.entity_id,
new_unique_id=mbus_device_id,
device_id=mbus_device_id,
)
except ValueError:
LOGGER.debug(
"Skip migration of %s because it already exists",
entity.entity_id,
)
else:
LOGGER.debug(
"Migrated entity %s from unique id %s to %s",
entity.entity_id,
entity.unique_id,
mbus_device_id,
)
# Cleanup old device
dev_entities = er.async_entries_for_device(
ent_reg, device_id, include_disabled_entities=True
)
if not dev_entities:
dev_reg.async_remove_device(device_id)
def create_mbus_entities(
hass: HomeAssistant, telegram: dict[str, DSMRObject], entry: ConfigEntry
) -> list[DSMREntity]:
"""Create MBUS Entities."""
entities = []
for idx in range(1, 5):
if (
device_type := getattr(obis_references, f"BELGIUM_MBUS{idx}_DEVICE_TYPE")
) not in telegram:
continue
if (type_ := int(telegram[device_type].value)) not in (3, 7):
continue
if (
identifier := getattr(
obis_references,
f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER",
)
) in telegram:
serial_ = telegram[identifier].value
rename_old_gas_to_mbus(hass, entry, serial_)
else:
serial_ = ""
if description := create_mbus_entity(idx, type_, telegram):
entities.append(
DSMREntity(
description,
entry,
telegram,
*device_class_and_uom(telegram, description), # type: ignore[arg-type]
serial_,
idx,
)
)
return entities
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the DSMR sensor."""
dsmr_version = entry.data[CONF_DSMR_VERSION]
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
if dsmr_version == "5B":
mbus_entities = create_mbus_entities(hass, telegram, entry)
for mbus_entity in mbus_entities:
entities.append(mbus_entity)
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)
)
@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)
if CONF_HOST in entry.data:
if protocol == DSMR_PROTOCOL:
create_reader = create_tcp_dsmr_reader
else:
create_reader = create_rfxtrx_tcp_dsmr_reader
reader_factory = partial(
create_reader,
entry.data[CONF_HOST],
entry.data[CONF_PORT],
dsmr_version,
update_entities_telegram,
loop=hass.loop,
keep_alive_interval=60,
)
else:
if protocol == DSMR_PROTOCOL:
create_reader = create_dsmr_reader
else:
create_reader = create_rfxtrx_dsmr_reader
reader_factory = partial(
create_reader,
entry.data[CONF_PORT],
dsmr_version,
update_entities_telegram,
loop=hass.loop,
)
async def connect_and_reconnect() -> None:
"""Connect to DSMR and keep reconnecting until Home Assistant stops."""
stop_listener = None
transport = None
protocol = None
while hass.state == CoreState.not_running or hass.is_running:
# Start DSMR asyncio.Protocol reader
# Reflect connected state in devices state by setting an
# empty telegram resulting in `unknown` states
update_entities_telegram({})
try:
transport, protocol = await hass.loop.create_task(reader_factory())
if transport:
# Register listener to close transport on HA shutdown
@callback
def close_transport(_event: Event) -> None:
"""Close the transport on HA shutdown."""
if not transport: # noqa: B023
return
transport.close() # noqa: B023
stop_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, close_transport
)
# Wait for reader to close
await protocol.wait_closed()
# Unexpected disconnect
if hass.state == CoreState.not_running or hass.is_running:
stop_listener()
transport = None
protocol = None
# Reflect disconnect state in devices state by setting an
# None telegram resulting in `unavailable` states
update_entities_telegram(None)
# throttle reconnect attempts
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
except (serial.SerialException, OSError):
# Log any error while establishing connection and drop to retry
# connection wait
LOGGER.exception("Error connecting to DSMR")
transport = None
protocol = None
# Reflect disconnect state in devices state by setting an
# None telegram resulting in `unavailable` states
update_entities_telegram(None)
# throttle reconnect attempts
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
except CancelledError:
# Reflect disconnect state in devices state by setting an
# None telegram resulting in `unavailable` states
update_entities_telegram(None)
if stop_listener and (
hass.state == CoreState.not_running or hass.is_running
):
stop_listener()
if transport:
transport.close()
if protocol:
await protocol.wait_closed()
return
# Can't be hass.async_add_job because job runs forever
task = asyncio.create_task(connect_and_reconnect())
@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)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop)
)
# Save the task to be able to cancel it when unloading
hass.data[DOMAIN][entry.entry_id][DATA_TASK] = task
class DSMREntity(SensorEntity):
"""Entity reading values from DSMR telegram."""
entity_description: DSMRSensorEntityDescription
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
entity_description: DSMRSensorEntityDescription,
entry: ConfigEntry,
telegram: dict[str, DSMRObject],
device_class: SensorDeviceClass,
native_unit_of_measurement: str | None,
serial_id: str = "",
mbus_id: int = 0,
) -> 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 = telegram
device_serial = entry.data[CONF_SERIAL_ID]
device_name = DEVICE_NAME_ELECTRICITY
if entity_description.is_gas:
if serial_id:
device_serial = serial_id
else:
device_serial = entry.data[CONF_SERIAL_ID_GAS]
device_name = DEVICE_NAME_GAS
if entity_description.is_water:
if serial_id:
device_serial = serial_id
device_name = DEVICE_NAME_WATER
if device_serial is None:
device_serial = entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_serial)},
name=device_name,
)
if mbus_id != 0:
if serial_id:
self._attr_unique_id = f"{device_serial}"
else:
self._attr_unique_id = f"{device_serial}_{mbus_id}"
else:
self._attr_unique_id = f"{device_serial}_{entity_description.key}"
@callback
def update_data(self, telegram: dict[str, DSMRObject] | None) -> None:
"""Update data."""
self.telegram = telegram
if self.hass and (
telegram is None or self.entity_description.obis_reference in telegram
):
self.async_write_ha_state()
def get_dsmr_object_attr(self, attribute: str) -> str | None:
"""Read attribute from last received telegram for this DSMR object."""
# Make sure telegram contains an object for this entities obis
if (
self.telegram is None
or self.entity_description.obis_reference not in self.telegram
):
return None
# Get the attribute value if the object has it
dsmr_object = self.telegram[self.entity_description.obis_reference]
attr: str | None = getattr(dsmr_object, attribute)
return attr
@property
def available(self) -> bool:
"""Entity is only available if there is a telegram."""
return self.telegram is not None
@property
def native_value(self) -> StateType:
"""Return the state of sensor, if available, translate if needed."""
value: StateType
if (value := self.get_dsmr_object_attr("value")) is None:
return None
if (
self.entity_description.obis_reference
== obis_references.ELECTRICITY_ACTIVE_TARIFF
):
return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION])
with suppress(TypeError):
value = round(float(value), DEFAULT_PRECISION)
# Make sure we do not return a zero value for an energy sensor
if not value and self.state_class == SensorStateClass.TOTAL_INCREASING:
return None
return value
@staticmethod
def translate_tariff(value: str, dsmr_version: str) -> str | None:
"""Convert 2/1 to normal/low depending on DSMR version."""
# DSMR V5B: Note: In Belgium values are swapped:
# Rate code 2 is used for low rate and rate code 1 is used for normal rate.
if dsmr_version == "5B":
if value == "0001":
value = "0002"
elif value == "0002":
value = "0001"
# DSMR V2.2: Note: Rate code 1 is used for low rate and rate code 2 is
# used for normal rate.
if value == "0002":
return "normal"
if value == "0001":
return "low"
return None