diff --git a/README.md b/README.md index e1afa48..07a4ab0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,85 @@ -# homeassistant-edl21-custom-interval -A replacement for the core edl21 sml integration with configurable scan interval to get a lot more detailed power usage info +# EDL21 + +The `edl21` integration lets you read German EDL21 smart meters using [SML](https://de.wikipedia.org/wiki/Smart_Message_Language) from Home Assistant. + +In order to connect to the smart meter, an infrared transceiver is required. + +Compatible transceivers: + +- [DIY](https://wiki.volkszaehler.org/hardware/controllers/ir-schreib-lesekopf-rs232-ausgang) +- [Weidmann Elektronik Schreib-/Lesekopf USB](https://shop.weidmann-elektronik.de/index.php?page=product&info=24) +- [USB IR Lesekopf EHZ Lese-Schreib-Kopf Volkszähler Hichi Smartmeter](https://www.ebay.de/itm/313455434998) + +Tested smart meters: + +- APATOR Norax 3D (enable InF Mode as described in manual to retrieve full data) +- DZG DWS76 (enable InF as described in manual to retrieve full data) +- Iskraemeco MT175 (ISKRA MT175-D2A51-V22-K0t) +- EMH metering eHZ Generation K (enable InF as described in manual to retrieve full data) +- efr SGM-C4 (enable InF as described in manual to retrieve full data) + +## Background + +Many community users need a lot quicker scan interval then the forced 60 seconds of the official `edl21` integration. Electical power peaks will not be detected with the standard 60 seconds measurement interval. +Thanx to @jwefers who listened to the community and added an configuration option `scan_interval_seconds` that allows to set the scan interval down to 1 second. The smart meter delivers values every second but you can set it to whatever you like. Take care of your disk space when using such small intervalls. Recommendation is 10 seconds what is default of this custom integration. +But configurable intervals are not allowed anymore in the official repo (more info here ) so this was the reason for this custom component. + +## Configuration + +To set it up, follow this procedure: + +1. Copy the folder edl21 to your homeasstant/conf/custom_components/ +2. Restart homeassistant + + In case you alredy configured the edl21 platform you will get measurements every 10 seconds. + If you are fine with 10 seconds interval you are finished now. + + In case you want to set another scan interval you just have to add the parameter `scan_interval_seconds` to your `configuration.yaml` file: + + ```yaml + sensor: + - platform: edl21 + serial_port: /dev/ttyUSB0 + scan_interval_seconds: 5 + ``` + +### Configuration variables + +**name** +- The friendly name of the smart meter. +- required: false +- type: string + +**serial_port** +- The device to communicate with. When using ser2net, use socket://host:port. +- required: true +- type: string + +**scan_interval_seconds** +- The interval for measurements. +- required: false +- type: int + + +## InF Mode + +To enable InF mode there are different steps needed based on the meter type but most commonly you have to enter the PIN you received from your grid operator. Once you have it, enter it into the meter and switch to the InF menu where you can switch from InF=Off to InF=On. +Entering this can be done using a flashlight or (if available) via the physical button on the meter. + +For the efr SGM-C4 it is: + +- flashing three times to enter pin mode +- entering pin using quicker flashes, wait for 3 seconds for next digit +- pin accepted +- flashing 7 times to get to InF=OFF +- 5-second flash to switch to InF=OFF + +You will now get more readings like current Power, Voltage, and phase angle. Some meters don´t have this, in that case only an overall reading is provided. + +### ser2net + +To use this integration with a remote transceiver you could use [ser2net](https://linux.die.net/man/8/ser2net). + +Example `ser2net.conf` configuration file: + +> 2001:raw:0:/dev/ttyUSB0:9600 8DATABITS NONE 1STOPBIT diff --git a/edl21/__init__.py b/edl21/__init__.py new file mode 100644 index 0000000..f1cd598 --- /dev/null +++ b/edl21/__init__.py @@ -0,0 +1 @@ +"""The edl21 component.""" diff --git a/edl21/manifest.json b/edl21/manifest.json new file mode 100755 index 0000000..8d1e37e --- /dev/null +++ b/edl21/manifest.json @@ -0,0 +1,10 @@ +{ + "version": "2.0.0", + "domain": "edl21", + "name": "EDL21", + "documentation": "https://www.home-assistant.io/integrations/edl21", + "requirements": ["pysml==0.0.8"], + "codeowners": [], + "iot_class": "local_push", + "loggers": ["sml"] +} diff --git a/edl21/sensor.py b/edl21/sensor.py new file mode 100644 index 0000000..78760e0 --- /dev/null +++ b/edl21/sensor.py @@ -0,0 +1,498 @@ +"""Support for EDL21 Smart Meters.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +from datetime import timedelta +import logging +from typing import Any + +from sml import SmlGetListResponse +from sml.asyncio import SmlProtocol +import voluptuous as vol + +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONF_NAME, + DEGREE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, + FREQUENCY_HERTZ, + POWER_WATT, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.dt import utcnow + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "edl21" +CONF_SERIAL_PORT = "serial_port" +CONF_SCAN_INTERVAL_SECONDS = "scan_interval_seconds" +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) +SIGNAL_EDL21_TELEGRAM = "edl21_telegram" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_SERIAL_PORT): cv.string, + vol.Optional(CONF_NAME, default=""): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL_SECONDS, default=MIN_TIME_BETWEEN_UPDATES + ): cv.positive_int, + }, +) + +# OBIS format: A-B:C.D.E*F +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + # A=1: Electricity + # C=0: General purpose objects + # D=0: Free ID-numbers for utilities + SensorEntityDescription( + key="1-0:0.0.9*255", name="Electricity ID", icon="mdi:flash" + ), + # D=2: Program entries + SensorEntityDescription( + key="1-0:0.2.0*0", name="Configuration program version number", icon="mdi:flash" + ), + SensorEntityDescription( + key="1-0:0.2.0*1", name="Firmware version number", icon="mdi:flash" + ), + # C=1: Active power + + # D=8: Time integral 1 + # E=0: Total + SensorEntityDescription( + key="1-0:1.8.0*255", + name="Positive active energy total", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + # E=1: Rate 1 + SensorEntityDescription( + key="1-0:1.8.1*255", + name="Positive active energy in tariff T1", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + # E=2: Rate 2 + SensorEntityDescription( + key="1-0:1.8.2*255", + name="Positive active energy in tariff T2", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + # D=17: Time integral 7 + # E=0: Total + SensorEntityDescription( + key="1-0:1.17.0*255", + name="Last signed positive active energy total", + ), + # C=2: Active power - + # D=8: Time integral 1 + # E=0: Total + SensorEntityDescription( + key="1-0:2.8.0*255", + name="Negative active energy total", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + # E=1: Rate 1 + SensorEntityDescription( + key="1-0:2.8.1*255", + name="Negative active energy in tariff T1", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + # E=2: Rate 2 + SensorEntityDescription( + key="1-0:2.8.2*255", + name="Negative active energy in tariff T2", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + # C=14: Supply frequency + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:14.7.0*255", name="Supply frequency", icon="mdi:sine-wave" + ), + # C=15: Active power absolute + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:15.7.0*255", + name="Absolute active instantaneous power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + # C=16: Active power sum + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:16.7.0*255", + name="Sum active instantaneous power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + # C=31: Active amperage L1 + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:31.7.0*255", + name="L1 active instantaneous amperage", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + # C=32: Active voltage L1 + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:32.7.0*255", + name="L1 active instantaneous voltage", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + ), + # C=36: Active power L1 + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:36.7.0*255", + name="L1 active instantaneous power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + # C=51: Active amperage L2 + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:51.7.0*255", + name="L2 active instantaneous amperage", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + # C=52: Active voltage L2 + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:52.7.0*255", + name="L2 active instantaneous voltage", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + ), + # C=56: Active power L2 + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:56.7.0*255", + name="L2 active instantaneous power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + # C=71: Active amperage L3 + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:71.7.0*255", + name="L3 active instantaneous amperage", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + # C=72: Active voltage L3 + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:72.7.0*255", + name="L3 active instantaneous voltage", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + ), + # C=76: Active power L3 + # D=7: Instantaneous value + # E=0: Total + SensorEntityDescription( + key="1-0:76.7.0*255", + name="L3 active instantaneous power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + # C=81: Angles + # D=7: Instantaneous value + # E=1: U(L2) x U(L1) + # E=2: U(L3) x U(L1) + # E=4: U(L1) x I(L1) + # E=15: U(L2) x I(L2) + # E=26: U(L3) x I(L3) + SensorEntityDescription( + key="1-0:81.7.1*255", name="U(L2)/U(L1) phase angle", icon="mdi:sine-wave" + ), + SensorEntityDescription( + key="1-0:81.7.2*255", name="U(L3)/U(L1) phase angle", icon="mdi:sine-wave" + ), + SensorEntityDescription( + key="1-0:81.7.4*255", name="U(L1)/I(L1) phase angle", icon="mdi:sine-wave" + ), + SensorEntityDescription( + key="1-0:81.7.15*255", name="U(L2)/I(L2) phase angle", icon="mdi:sine-wave" + ), + SensorEntityDescription( + key="1-0:81.7.26*255", name="U(L3)/I(L3) phase angle", icon="mdi:sine-wave" + ), + # C=96: Electricity-related service entries + SensorEntityDescription( + key="1-0:96.1.0*255", name="Metering point ID 1", icon="mdi:flash" + ), + SensorEntityDescription( + key="1-0:96.5.0*255", name="Internal operating status", icon="mdi:flash" + ), +) + +SENSORS = {desc.key: desc for desc in SENSOR_TYPES} + +SENSOR_UNIT_MAPPING = { + "Wh": ENERGY_WATT_HOUR, + "kWh": ENERGY_KILO_WATT_HOUR, + "W": POWER_WATT, + "A": ELECTRIC_CURRENT_AMPERE, + "V": ELECTRIC_POTENTIAL_VOLT, + "°": DEGREE, + "Hz": FREQUENCY_HERTZ, +} + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> 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_BLACKLIST = { + # C=96: Electricity-related service entries + "1-0:96.50.1*1", # Manufacturer specific EFR SGM-C4 Hardware version + "1-0:96.50.1*4", # Manufacturer specific EFR SGM-C4 Hardware version + "1-0:96.50.4*4", # Manufacturer specific EFR SGM-C4 Parameters version + "1-0:96.90.2*1", # Manufacturer specific EFR SGM-C4 Firmware Checksum + "1-0:96.90.2*2", # Manufacturer specific EFR SGM-C4 Firmware Checksum + # C=97: Electricity-related service entries + "1-0:97.97.0*0", # Manufacturer specific EFR SGM-C4 Error register + # 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[tuple[str, str]] = 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"]) + + if (scan_interval_seconds := config[CONF_SCAN_INTERVAL_SECONDS]) is not None: + # EDL21 pushes one data package per second. In order to not lose + # packages, when time passed since last package is an iota earlier than + # the configured interval (if 1 second is selected), make the actual scan + # interval a bit smaller. + self._min_time = timedelta( + seconds=scan_interval_seconds - 1, milliseconds=900 + ) + else: + self._min_time = MIN_TIME_BETWEEN_UPDATES + + 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", []): + if not (obis := telegram.get("objName")): + continue + + if (electricity_id, obis) in self._registered_obis: + async_dispatcher_send( + self._hass, SIGNAL_EDL21_TELEGRAM, electricity_id, telegram + ) + else: + entity_description = SENSORS.get(obis) + if entity_description and entity_description.name: + name = entity_description.name + if self._name: + name = f"{self._name}: {name}" + + new_entities.append( + EDL21Entity( + electricity_id, + obis, + name, + entity_description, + telegram, + self._min_time, + ) + ) + self._registered_obis.add((electricity_id, obis)) + elif obis not in self._OBIS_BLACKLIST: + _LOGGER.warning( + "Unhandled sensor %s detected. Please report at %s", + obis, + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+edl21%22", + ) + 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 = er.async_get(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(SensorEntity): + """Entity reading values from EDL21 telegram.""" + + _attr_should_poll = False + + def __init__( + self, + electricity_id, + obis, + name, + entity_description, + telegram, + min_time: timedelta = MIN_TIME_BETWEEN_UPDATES, + ): + """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 + self._last_update = utcnow() + self._state_attrs = { + "status": "status", + "valTime": "val_time", + "scaler": "scaler", + "valueSignature": "value_signature", + } + self._async_remove_dispatcher: Callable[[], None] | None = None + self.entity_description = entity_description + + async def async_added_to_hass(self) -> None: + """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) -> None: + """Run when entity will be removed from hass.""" + if self._async_remove_dispatcher: + self._async_remove_dispatcher() + + @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) -> str | None: + """Return a name.""" + return self._name + + @property + def native_value(self) -> str: + """Return the value of the last received telegram.""" + return self._telegram.get("value") + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Enumerate supported attributes.""" + return { + self._state_attrs[k]: v + for k, v in self._telegram.items() + if k in self._state_attrs + } + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if (unit := self._telegram.get("unit")) is None or unit == 0: + return None + + return SENSOR_UNIT_MAPPING[unit]