diff --git a/.coveragerc b/.coveragerc index 221b43998c4..eef39ded2bf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -180,6 +180,7 @@ omit = homeassistant/components/ecobee/weather.py homeassistant/components/econet/* homeassistant/components/ecovacs/* + homeassistant/components/edl21/* homeassistant/components/eddystone_temperature/sensor.py homeassistant/components/edimax/switch.py homeassistant/components/egardia/* diff --git a/CODEOWNERS b/CODEOWNERS index 8f821f43fec..8af5a8216a6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -90,6 +90,7 @@ homeassistant/components/dynalite/* @ziv1234 homeassistant/components/dyson/* @etheralm homeassistant/components/ecobee/* @marthoc homeassistant/components/ecovacs/* @OverloadUT +homeassistant/components/edl21/* @mtdcr homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/elgato/* @frenck diff --git a/homeassistant/components/edl21/__init__.py b/homeassistant/components/edl21/__init__.py new file mode 100644 index 00000000000..f1cd5984744 --- /dev/null +++ b/homeassistant/components/edl21/__init__.py @@ -0,0 +1 @@ +"""The edl21 component.""" diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json new file mode 100644 index 00000000000..313ac2c262e --- /dev/null +++ b/homeassistant/components/edl21/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "edl21", + "name": "EDL21", + "documentation": "https://www.home-assistant.io/integrations/edl21", + "requirements": [ + "pysml==0.0.2" + ], + "dependencies": [], + "codeowners": [ + "@mtdcr" + ] +} diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py new file mode 100644 index 00000000000..a4e0ca734fa --- /dev/null +++ b/homeassistant/components/edl21/sensor.py @@ -0,0 +1,196 @@ +"""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.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.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}) + + +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=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", + } + _OBIS_BLACKLIST = { + # 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._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) + + new_entities = [] + for telegram in message_body.get("valList", []): + obis = telegram.get("objName") + if not obis: + continue + + if obis in self._registered_obis: + async_dispatcher_send(self._hass, SIGNAL_EDL21_TELEGRAM, telegram) + else: + name = self._OBIS_NAMES.get(obis) + if name: + new_entities.append(EDL21Entity(obis, name, telegram)) + self._registered_obis.add(obis) + elif obis not in self._OBIS_BLACKLIST: + _LOGGER.warning( + "Unhandled sensor %s detected. Please report at " + 'https://github.com/home-assistant/home-assistant/issues?q=is%%3Aissue+label%%3A"integration%%3A+edl21"+', + obis, + ) + self._OBIS_BLACKLIST.add(obis) + + if new_entities: + self._async_add_entities(new_entities, update_before_add=True) + + +class EDL21Entity(Entity): + """Entity reading values from EDL21 telegram.""" + + def __init__(self, obis, name, telegram): + """Initialize an EDL21Entity.""" + self._obis = obis + self._name = name + 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(telegram): + """Update attributes from last received telegram for this object.""" + 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._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 device_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 diff --git a/requirements_all.txt b/requirements_all.txt index 121646cae11..fad600e918d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1529,6 +1529,9 @@ pysmartthings==0.7.0 # homeassistant.components.smarty pysmarty==0.8 +# homeassistant.components.edl21 +pysml==0.0.2 + # homeassistant.components.snmp pysnmp==4.4.12