core/homeassistant/components/dsmr/sensor.py

656 lines
24 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 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,
EVENT_HOMEASSISTANT_STOP,
EntityCategory,
UnitOfEnergy,
UnitOfVolume,
)
from homeassistant.core import CoreState, Event, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import EventType, StateType
from homeassistant.util import Throttle
from .const import (
CONF_DSMR_VERSION,
CONF_PRECISION,
CONF_PROTOCOL,
CONF_RECONNECT_INTERVAL,
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,
DOMAIN,
DSMR_PROTOCOL,
LOGGER,
)
UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS}
@dataclass
class DSMRSensorEntityDescriptionMixin:
"""Mixin for required keys."""
obis_reference: str
@dataclass
class DSMRSensorEntityDescription(
SensorEntityDescription, DSMRSensorEntityDescriptionMixin
):
"""Represents an DSMR Sensor."""
dsmr_versions: set[str] | None = None
is_gas: bool = False
SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="current_electricity_usage",
translation_key="current_electricity_usage",
obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE,
device_class=SensorDeviceClass.POWER,
force_update=True,
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,
force_update=True,
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,
force_update=True,
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"},
force_update=True,
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"},
force_update=True,
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"},
force_update=True,
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", "5B", "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", "5B", "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", "5B", "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", "5B", "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", "5B", "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", "5B", "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", "5B", "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", "5B", "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.POWER,
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"},
force_update=True,
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"},
force_update=True,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
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,
force_update=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
DSMRSensorEntityDescription(
key="belgium_5min_gas_meter_reading",
translation_key="gas_meter_reading",
obis_reference=obis_references.BELGIUM_5MIN_GAS_METER_READING,
dsmr_versions={"5B"},
is_gas=True,
force_update=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,
force_update=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)
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 = [
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)
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."""
# Make all device entities aware of new telegram
for entity in entities:
entity.update_data(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: EventType) -> 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(
entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL)
)
except (serial.serialutil.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(
entry.data.get(CONF_RECONNECT_INTERVAL, 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() # pylint: disable=not-callable
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:
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
) -> None:
"""Initialize entity."""
self.entity_description = entity_description
self._entry = entry
self.telegram: dict[str, DSMRObject] | None = {}
device_serial = entry.data[CONF_SERIAL_ID]
device_name = DEVICE_NAME_ELECTRICITY
if entity_description.is_gas:
device_serial = entry.data[CONF_SERIAL_ID_GAS]
device_name = DEVICE_NAME_GAS
if device_serial is None:
device_serial = entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_serial)},
name=device_name,
)
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 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."""
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), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION)
)
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."""
# 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