core/homeassistant/components/dsmr/sensor.py

853 lines
31 KiB
Python
Raw Normal View History

"""Support for Dutch Smart Meter (also known as Smartmeter or P1 port)."""
2021-03-17 22:43:55 +00:00
from __future__ import annotations
2016-11-23 07:03:39 +00:00
import asyncio
from asyncio import CancelledError
2024-07-30 14:51:02 +00:00
from collections.abc import Callable, Generator
from contextlib import suppress
from dataclasses import dataclass
from datetime import timedelta
2024-07-30 14:51:02 +00:00
from enum import IntEnum
from functools import partial
2016-11-23 07:03:39 +00:00
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,
)
2024-07-30 14:51:02 +00:00
from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram
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,
2023-12-13 16:05:37 +00:00
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 . import DsmrConfigEntry
from .const import (
CONF_DSMR_VERSION,
CONF_SERIAL_ID,
CONF_SERIAL_ID_GAS,
CONF_TIME_BETWEEN_UPDATE,
DEFAULT_PRECISION,
DEFAULT_RECONNECT_INTERVAL,
DEFAULT_TIME_BETWEEN_UPDATE,
DEVICE_NAME_ELECTRICITY,
DEVICE_NAME_GAS,
DEVICE_NAME_HEAT,
DEVICE_NAME_WATER,
DOMAIN,
DSMR_PROTOCOL,
LOGGER,
)
2016-11-23 07:03:39 +00:00
EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}"
UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS}
2016-11-23 07:03:39 +00:00
@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
is_heat: bool = False
obis_reference: str
2024-07-30 14:51:02 +00:00
class MbusDeviceType(IntEnum):
"""Types of mbus devices (13757-3:2013)."""
GAS = 3
HEAT = 4
2024-07-30 14:51:02 +00:00
WATER = 7
SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="timestamp",
obis_reference="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="CURRENT_ELECTRICITY_USAGE",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="current_electricity_delivery",
translation_key="current_electricity_delivery",
obis_reference="CURRENT_ELECTRICITY_DELIVERY",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="electricity_active_tariff",
translation_key="electricity_active_tariff",
obis_reference="ELECTRICITY_ACTIVE_TARIFF",
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENUM,
options=["low", "normal"],
),
DSMRSensorEntityDescription(
key="electricity_used_tariff_1",
translation_key="electricity_used_tariff_1",
obis_reference="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="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="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="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="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="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="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="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="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="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="SHORT_POWER_FAILURE_COUNT",
2023-10-19 06:57:50 +00:00
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="long_power_failure_count",
translation_key="long_power_failure_count",
obis_reference="LONG_POWER_FAILURE_COUNT",
2023-10-19 06:57:50 +00:00
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="voltage_sag_l1_count",
translation_key="voltage_sag_l1_count",
obis_reference="VOLTAGE_SAG_L1_COUNT",
2023-10-19 06:57:50 +00:00
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="VOLTAGE_SAG_L2_COUNT",
2023-10-19 06:57:50 +00:00
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="VOLTAGE_SAG_L3_COUNT",
2023-10-19 06:57:50 +00:00
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="VOLTAGE_SWELL_L1_COUNT",
2023-10-19 06:57:50 +00:00
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="voltage_swell_l2_count",
translation_key="voltage_swell_l2_count",
obis_reference="VOLTAGE_SWELL_L2_COUNT",
2023-10-19 06:57:50 +00:00
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="voltage_swell_l3_count",
translation_key="voltage_swell_l3_count",
obis_reference="VOLTAGE_SWELL_L3_COUNT",
2023-10-19 06:57:50 +00:00
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
key="instantaneous_voltage_l1",
translation_key="instantaneous_voltage_l1",
obis_reference="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="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="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="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="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="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="ACTUAL_TRESHOLD_ELECTRICITY",
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",
2024-07-30 14:51:02 +00:00
obis_reference="FUSE_THRESHOLD_L1",
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="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="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="BELGIUM_CURRENT_AVERAGE_DEMAND",
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="belgium_maximum_demand_current_month",
translation_key="maximum_demand_current_month",
obis_reference="BELGIUM_MAXIMUM_DEMAND_MONTH",
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
DSMRSensorEntityDescription(
key="hourly_gas_meter_reading",
translation_key="gas_meter_reading",
obis_reference="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="GAS_METER_READING",
dsmr_versions={"2.2"},
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)
2024-07-30 14:51:02 +00:00
SENSORS_MBUS_DEVICE_TYPE: dict[int, tuple[DSMRSensorEntityDescription, ...]] = {
MbusDeviceType.GAS: (
DSMRSensorEntityDescription(
key="gas_reading",
translation_key="gas_meter_reading",
2024-07-30 14:51:02 +00:00
obis_reference="MBUS_METER_READING",
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
2024-07-30 14:51:02 +00:00
),
),
MbusDeviceType.HEAT: (
DSMRSensorEntityDescription(
key="heat_reading",
translation_key="heat_meter_reading",
obis_reference="MBUS_METER_READING",
is_heat=True,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
),
2024-07-30 14:51:02 +00:00
MbusDeviceType.WATER: (
DSMRSensorEntityDescription(
key="water_reading",
translation_key="water_meter_reading",
2024-07-30 14:51:02 +00:00
obis_reference="MBUS_METER_READING",
is_water=True,
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
2024-07-30 14:51:02 +00:00
),
),
}
def device_class_and_uom(
2024-07-30 14:51:02 +00:00
data: Telegram | MbusDevice,
entity_description: DSMRSensorEntityDescription,
) -> tuple[SensorDeviceClass | None, str | None]:
"""Get native unit of measurement from telegram,."""
2024-07-30 14:51:02 +00:00
dsmr_object = getattr(data, 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)
for dev_id in (mbus_device_id, entry.entry_id):
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, dev_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"
) or entity.unique_id.endswith("hourly_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)
2024-07-30 14:51:02 +00:00
def is_supported_description(
data: Telegram | MbusDevice,
description: DSMRSensorEntityDescription,
dsmr_version: str,
) -> bool:
"""Check if this is a supported description for this telegram."""
return hasattr(data, description.obis_reference) and (
description.dsmr_versions is None or dsmr_version in description.dsmr_versions
)
def create_mbus_entities(
2024-07-30 14:51:02 +00:00
hass: HomeAssistant, telegram: Telegram, entry: ConfigEntry, dsmr_version: str
) -> Generator[DSMREntity]:
"""Create MBUS Entities."""
2024-07-30 14:51:02 +00:00
mbus_devices: list[MbusDevice] = getattr(telegram, "MBUS_DEVICES", [])
for device in mbus_devices:
if (device_type := getattr(device, "MBUS_DEVICE_TYPE", None)) is None:
continue
2024-07-30 14:51:02 +00:00
type_ = int(device_type.value)
if type_ not in SENSORS_MBUS_DEVICE_TYPE:
LOGGER.warning("Unsupported MBUS_DEVICE_TYPE (%d)", type_)
continue
2024-07-30 14:51:02 +00:00
if identifier := getattr(device, "MBUS_EQUIPMENT_IDENTIFIER", None):
serial_ = identifier.value
rename_old_gas_to_mbus(hass, entry, serial_)
else:
serial_ = ""
2024-07-30 14:51:02 +00:00
for description in SENSORS_MBUS_DEVICE_TYPE.get(type_, ()):
if not is_supported_description(device, description, dsmr_version):
continue
yield DSMREntity(
description,
entry,
telegram,
*device_class_and_uom(device, description), # type: ignore[arg-type]
serial_,
device.channel_id,
)
2024-07-30 14:51:02 +00:00
def get_dsmr_object(
telegram: Telegram | None, mbus_id: int, obis_reference: str
) -> DSMRObject | None:
"""Extract DSMR object from telegram."""
if not telegram:
return None
telegram_or_device: Telegram | MbusDevice | None = telegram
if mbus_id:
telegram_or_device = telegram.get_mbus_device_by_channel(mbus_id)
if telegram_or_device is None:
return None
return getattr(telegram_or_device, obis_reference, None)
async def async_setup_entry(
hass: HomeAssistant, entry: DsmrConfigEntry, 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: Telegram) -> 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
2024-07-30 14:51:02 +00:00
entities.extend(create_mbus_entities(hass, telegram, entry, dsmr_version))
entities.extend(
[
DSMREntity(
description,
entry,
telegram,
*device_class_and_uom(telegram, description), # type: ignore[arg-type]
)
for description in SENSORS
2024-07-30 14:51:02 +00:00
if is_supported_description(telegram, description, dsmr_version)
and (
(not description.is_gas and not description.is_heat)
or CONF_SERIAL_ID_GAS in entry.data
)
]
)
async_add_entities(entities)
2016-11-23 07:03:39 +00:00
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: Telegram | 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)
2016-11-23 07:03:39 +00:00
entry.runtime_data.telegram = telegram
if not initialized and telegram:
initialized = True
async_dispatcher_send(
hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), telegram
)
2018-01-27 19:58:27 +00:00
# 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,
2019-07-31 19:25:30 +00:00
update_entities_telegram,
loop=hass.loop,
keep_alive_interval=60,
2019-07-31 19:25:30 +00:00
)
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,
2019-07-31 19:25:30 +00:00
update_entities_telegram,
loop=hass.loop,
)
2016-11-23 07:03:39 +00:00
async def connect_and_reconnect() -> None:
2017-09-25 20:19:44 +00:00
"""Connect to DSMR and keep reconnecting until Home Assistant stops."""
2021-01-02 00:34:10 +00:00
stop_listener = None
transport = None
protocol = None
while hass.state is 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:
2019-07-31 19:25:30 +00:00
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()
2021-01-02 00:34:10 +00:00
# Unexpected disconnect
if hass.state is CoreState.not_running or hass.is_running:
2021-01-02 00:34:10 +00:00
stop_listener()
transport = None
protocol = None
2017-09-25 20:19:44 +00:00
# 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)
2021-08-23 10:32:57 +00:00
if stop_listener and (
hass.state is CoreState.not_running or hass.is_running
2021-08-23 10:32:57 +00:00
):
stop_listener()
if transport:
transport.close()
if protocol:
await protocol.wait_closed()
return
2017-09-25 20:19:44 +00:00
# Can't be hass.async_add_job because job runs forever
task = asyncio.create_task(connect_and_reconnect())
@callback
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
entry.runtime_data.task = task
2016-11-23 07:03:39 +00:00
class DSMREntity(SensorEntity):
2016-11-23 07:03:39 +00:00
"""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: Telegram,
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: Telegram | 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 entity_description.is_heat:
if serial_id:
device_serial = serial_id
device_name = DEVICE_NAME_HEAT
if device_serial is None:
device_serial = entry.entry_id
2021-10-22 15:00:00 +00:00
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_serial)},
name=device_name,
)
2024-07-30 14:51:02 +00:00
self._mbus_id = mbus_id
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: Telegram | None) -> None:
"""Update data."""
self.telegram = telegram
if self.hass and (
telegram is None
2024-07-30 14:51:02 +00:00
or get_dsmr_object(
telegram, self._mbus_id, self.entity_description.obis_reference
)
):
self.async_write_ha_state()
def get_dsmr_object_attr(self, attribute: str) -> str | None:
2016-11-23 07:03:39 +00:00
"""Read attribute from last received telegram for this DSMR object."""
2024-07-30 14:51:02 +00:00
# Get the object
dsmr_object = get_dsmr_object(
self.telegram, self._mbus_id, self.entity_description.obis_reference
)
if dsmr_object is None:
2016-11-23 07:03:39 +00:00
return None
2017-09-25 20:19:44 +00:00
# Get the attribute value if the object has it
attr: str | None = getattr(dsmr_object, attribute)
return attr
2016-11-23 07:03:39 +00:00
@property
def available(self) -> bool:
"""Entity is only available if there is a telegram."""
return self.telegram is not None
2016-11-23 07:03:39 +00:00
@property
def native_value(self) -> StateType:
2016-11-23 07:03:39 +00:00
"""Return the state of sensor, if available, translate if needed."""
value: StateType
2021-10-31 17:56:25 +00:00
if (value := self.get_dsmr_object_attr("value")) is None:
return None
2016-11-23 07:03:39 +00:00
if self.entity_description.obis_reference == "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
2016-11-23 07:03:39 +00:00
@staticmethod
def translate_tariff(value: str, dsmr_version: str) -> str | None:
2020-02-05 22:45:14 +00:00
"""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.
2019-07-31 19:25:30 +00:00
if value == "0002":
return "normal"
if value == "0001":
return "low"
return None