core/homeassistant/components/edl21/sensor.py

289 lines
9.7 KiB
Python

"""Support for EDL21 Smart Meters."""
from datetime import timedelta
import logging
from sml import SmlGetListResponse
from sml.asyncio import SmlProtocol
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_registry import async_get_registry
from homeassistant.helpers.typing import Optional
from homeassistant.util.dt import utcnow
_LOGGER = logging.getLogger(__name__)
DOMAIN = "edl21"
CONF_SERIAL_PORT = "serial_port"
ICON_POWER = "mdi:flash"
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
SIGNAL_EDL21_TELEGRAM = "edl21_telegram"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_SERIAL_PORT): cv.string,
vol.Optional(CONF_NAME, default=""): cv.string,
},
)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the EDL21 sensor."""
hass.data[DOMAIN] = EDL21(hass, config, async_add_entities)
await hass.data[DOMAIN].connect()
class EDL21:
"""EDL21 handles telegrams sent by a compatible smart meter."""
# OBIS format: A-B:C.D.E*F
_OBIS_NAMES = {
# A=1: Electricity
# C=0: General purpose objects
"1-0:0.0.9*255": "Electricity ID",
# C=1: Active power +
# D=8: Time integral 1
# E=0: Total
"1-0:1.8.0*255": "Positive active energy total",
# E=1: Rate 1
"1-0:1.8.1*255": "Positive active energy in tariff T1",
# E=2: Rate 2
"1-0:1.8.2*255": "Positive active energy in tariff T2",
# D=17: Time integral 7
# E=0: Total
"1-0:1.17.0*255": "Last signed positive active energy total",
# C=2: Active power -
# D=8: Time integral 1
# E=0: Total
"1-0:2.8.0*255": "Negative active energy total",
# E=1: Rate 1
"1-0:2.8.1*255": "Negative active energy in tariff T1",
# E=2: Rate 2
"1-0:2.8.2*255": "Negative active energy in tariff T2",
# C=15: Active power absolute
# D=7: Instantaneous value
# E=0: Total
"1-0:15.7.0*255": "Absolute active instantaneous power",
# C=16: Active power sum
# D=7: Instantaneous value
# E=0: Total
"1-0:16.7.0*255": "Sum active instantaneous power",
# C=31: Active amperage L1
# D=7: Instantaneous value
# E=0: Total
"1-0:31.7.0*255": "L1 active instantaneous amperage",
# C=36: Active power L1
# D=7: Instantaneous value
# E=0: Total
"1-0:36.7.0*255": "L1 active instantaneous power",
# C=51: Active amperage L2
# D=7: Instantaneous value
# E=0: Total
"1-0:51.7.0*255": "L2 active instantaneous amperage",
# C=56: Active power L2
# D=7: Instantaneous value
# E=0: Total
"1-0:56.7.0*255": "L2 active instantaneous power",
# C=71: Active amperage L3
# D=7: Instantaneous value
# E=0: Total
"1-0:71.7.0*255": "L3 active instantaneous amperage",
# C=76: Active power L3
# D=7: Instantaneous value
# E=0: Total
"1-0:76.7.0*255": "L3 active instantaneous power",
# C=96: Electricity-related service entries
"1-0:96.1.0*255": "Metering point ID 1",
}
_OBIS_BLACKLIST = {
# C=96: Electricity-related service entries
"1-0:96.50.1*1", # Manufacturer specific
# A=129: Manufacturer specific
"129-129:199.130.3*255", # Iskraemeco: Manufacturer
"129-129:199.130.5*255", # Iskraemeco: Public Key
}
def __init__(self, hass, config, async_add_entities) -> None:
"""Initialize an EDL21 object."""
self._registered_obis = set()
self._hass = hass
self._async_add_entities = async_add_entities
self._name = config[CONF_NAME]
self._proto = SmlProtocol(config[CONF_SERIAL_PORT])
self._proto.add_listener(self.event, ["SmlGetListResponse"])
async def connect(self):
"""Connect to an EDL21 reader."""
await self._proto.connect(self._hass.loop)
def event(self, message_body) -> None:
"""Handle events from pysml."""
assert isinstance(message_body, SmlGetListResponse)
electricity_id = None
for telegram in message_body.get("valList", []):
if telegram.get("objName") in ("1-0:0.0.9*255", "1-0:96.1.0*255"):
electricity_id = telegram.get("value")
break
if electricity_id is None:
return
electricity_id = electricity_id.replace(" ", "")
new_entities = []
for telegram in message_body.get("valList", []):
obis = telegram.get("objName")
if not obis:
continue
if (electricity_id, obis) in self._registered_obis:
async_dispatcher_send(
self._hass, SIGNAL_EDL21_TELEGRAM, electricity_id, telegram
)
else:
name = self._OBIS_NAMES.get(obis)
if name:
if self._name:
name = f"{self._name}: {name}"
new_entities.append(
EDL21Entity(electricity_id, obis, name, telegram)
)
self._registered_obis.add((electricity_id, obis))
elif obis not in self._OBIS_BLACKLIST:
_LOGGER.warning(
"Unhandled sensor %s detected. Please report at "
'https://github.com/home-assistant/core/issues?q=is%%3Aissue+label%%3A"integration%%3A+edl21"+',
obis,
)
self._OBIS_BLACKLIST.add(obis)
if new_entities:
self._hass.loop.create_task(self.add_entities(new_entities))
async def add_entities(self, new_entities) -> None:
"""Migrate old unique IDs, then add entities to hass."""
registry = await async_get_registry(self._hass)
for entity in new_entities:
old_entity_id = registry.async_get_entity_id(
"sensor", DOMAIN, entity.old_unique_id
)
if old_entity_id is not None:
_LOGGER.debug(
"Migrating unique_id from [%s] to [%s]",
entity.old_unique_id,
entity.unique_id,
)
if registry.async_get_entity_id("sensor", DOMAIN, entity.unique_id):
registry.async_remove(old_entity_id)
else:
registry.async_update_entity(
old_entity_id, new_unique_id=entity.unique_id
)
self._async_add_entities(new_entities, update_before_add=True)
class EDL21Entity(Entity):
"""Entity reading values from EDL21 telegram."""
def __init__(self, electricity_id, obis, name, telegram):
"""Initialize an EDL21Entity."""
self._electricity_id = electricity_id
self._obis = obis
self._name = name
self._unique_id = f"{electricity_id}_{obis}"
self._telegram = telegram
self._min_time = MIN_TIME_BETWEEN_UPDATES
self._last_update = utcnow()
self._state_attrs = {
"status": "status",
"valTime": "val_time",
"scaler": "scaler",
"valueSignature": "value_signature",
}
self._async_remove_dispatcher = None
async def async_added_to_hass(self):
"""Run when entity about to be added to hass."""
@callback
def handle_telegram(electricity_id, telegram):
"""Update attributes from last received telegram for this object."""
if self._electricity_id != electricity_id:
return
if self._obis != telegram.get("objName"):
return
if self._telegram == telegram:
return
now = utcnow()
if now - self._last_update < self._min_time:
return
self._telegram = telegram
self._last_update = now
self.async_write_ha_state()
self._async_remove_dispatcher = async_dispatcher_connect(
self.hass, SIGNAL_EDL21_TELEGRAM, handle_telegram
)
async def async_will_remove_from_hass(self):
"""Run when entity will be removed from hass."""
if self._async_remove_dispatcher:
self._async_remove_dispatcher()
@property
def should_poll(self) -> bool:
"""Do not poll."""
return False
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._unique_id
@property
def old_unique_id(self) -> str:
"""Return a less unique ID as used in the first version of edl21."""
return self._obis
@property
def name(self) -> Optional[str]:
"""Return a name."""
return self._name
@property
def state(self) -> str:
"""Return the value of the last received telegram."""
return self._telegram.get("value")
@property
def extra_state_attributes(self):
"""Enumerate supported attributes."""
return {
self._state_attrs[k]: v
for k, v in self._telegram.items()
if k in self._state_attrs
}
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return self._telegram.get("unit")
@property
def icon(self):
"""Return an icon."""
return ICON_POWER