Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Support for EDL21 Smart Meters."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 from datetime import timedelta
7 from typing import Any
8 
9 from sml import SmlGetListResponse
10 from sml.asyncio import SmlProtocol
11 
13  SensorDeviceClass,
14  SensorEntity,
15  SensorEntityDescription,
16  SensorStateClass,
17 )
18 from homeassistant.config_entries import ConfigEntry
19 from homeassistant.const import (
20  DEGREE,
21  UnitOfElectricCurrent,
22  UnitOfElectricPotential,
23  UnitOfEnergy,
24  UnitOfFrequency,
25  UnitOfPower,
26 )
27 from homeassistant.core import HomeAssistant, callback
28 from homeassistant.helpers.device_registry import DeviceInfo
30  async_dispatcher_connect,
31  async_dispatcher_send,
32 )
33 from homeassistant.helpers.entity_platform import AddEntitiesCallback
34 from homeassistant.util.dt import utcnow
35 
36 from .const import (
37  CONF_SERIAL_PORT,
38  DEFAULT_DEVICE_NAME,
39  DOMAIN,
40  LOGGER,
41  SIGNAL_EDL21_TELEGRAM,
42 )
43 
44 MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
45 
46 # OBIS format: A-B:C.D.E*F
47 SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
48  # A=1: Electricity
49  # C=0: General purpose objects
50  # D=0: Free ID-numbers for utilities
51  # E=0 Ownership ID
53  key="1-0:0.0.0*255",
54  translation_key="ownership_id",
55  entity_registry_enabled_default=False,
56  ),
57  # E=9: Electrity ID
59  key="1-0:0.0.9*255",
60  translation_key="electricity_id",
61  ),
62  # D=2: Program entries
64  key="1-0:0.2.0*0",
65  translation_key="configuration_program_version_number",
66  ),
68  key="1-0:0.2.0*1",
69  translation_key="firmware_version_number",
70  ),
71  # C=1: Active power +
72  # D=7: Current value
73  # E=0: Total
75  key="1-0:1.7.0*255",
76  translation_key="positive_active_instantaneous_power",
77  state_class=SensorStateClass.MEASUREMENT,
78  device_class=SensorDeviceClass.POWER,
79  ),
80  # C=1: Active energy +
81  # D=8: Time integral 1
82  # E=0: Total
84  key="1-0:1.8.0*255",
85  translation_key="positive_active_energy_total",
86  state_class=SensorStateClass.TOTAL_INCREASING,
87  device_class=SensorDeviceClass.ENERGY,
88  ),
89  # E=1: Rate 1
91  key="1-0:1.8.1*255",
92  translation_key="positive_active_energy_tariff_t1",
93  state_class=SensorStateClass.TOTAL_INCREASING,
94  device_class=SensorDeviceClass.ENERGY,
95  ),
96  # E=2: Rate 2
98  key="1-0:1.8.2*255",
99  translation_key="positive_active_energy_tariff_t2",
100  state_class=SensorStateClass.TOTAL_INCREASING,
101  device_class=SensorDeviceClass.ENERGY,
102  ),
103  # D=17: Time integral 7
104  # E=0: Total
106  key="1-0:1.17.0*255",
107  translation_key="last_signed_positive_active_energy_total",
108  ),
109  # C=2: Active energy -
110  # D=8: Time integral 1
111  # E=0: Total
113  key="1-0:2.8.0*255",
114  translation_key="negative_active_energy_total",
115  state_class=SensorStateClass.TOTAL_INCREASING,
116  device_class=SensorDeviceClass.ENERGY,
117  ),
118  # E=1: Rate 1
120  key="1-0:2.8.1*255",
121  translation_key="negative_active_energy_tariff_t1",
122  state_class=SensorStateClass.TOTAL_INCREASING,
123  device_class=SensorDeviceClass.ENERGY,
124  ),
125  # E=2: Rate 2
127  key="1-0:2.8.2*255",
128  translation_key="negative_active_energy_tariff_t2",
129  state_class=SensorStateClass.TOTAL_INCREASING,
130  device_class=SensorDeviceClass.ENERGY,
131  ),
132  # C=14: Supply frequency
133  # D=7: Instantaneous value
134  # E=0: Total
136  key="1-0:14.7.0*255",
137  translation_key="supply_frequency",
138  ),
139  # C=15: Active power absolute
140  # D=7: Instantaneous value
141  # E=0: Total
143  key="1-0:15.7.0*255",
144  translation_key="absolute_active_instantaneous_power",
145  state_class=SensorStateClass.MEASUREMENT,
146  device_class=SensorDeviceClass.POWER,
147  ),
148  # C=16: Active power sum
149  # D=7: Instantaneous value
150  # E=0: Total
152  key="1-0:16.7.0*255",
153  translation_key="sum_active_instantaneous_power",
154  state_class=SensorStateClass.MEASUREMENT,
155  device_class=SensorDeviceClass.POWER,
156  ),
157  # C=31: Active amperage L1
158  # D=7: Instantaneous value
159  # E=0: Total
161  key="1-0:31.7.0*255",
162  translation_key="l1_active_instantaneous_amperage",
163  state_class=SensorStateClass.MEASUREMENT,
164  device_class=SensorDeviceClass.CURRENT,
165  ),
166  # C=32: Active voltage L1
167  # D=7: Instantaneous value
168  # E=0: Total
170  key="1-0:32.7.0*255",
171  translation_key="l1_active_instantaneous_voltage",
172  state_class=SensorStateClass.MEASUREMENT,
173  device_class=SensorDeviceClass.VOLTAGE,
174  ),
175  # C=36: Active power L1
176  # D=7: Instantaneous value
177  # E=0: Total
179  key="1-0:36.7.0*255",
180  translation_key="l1_active_instantaneous_power",
181  state_class=SensorStateClass.MEASUREMENT,
182  device_class=SensorDeviceClass.POWER,
183  ),
184  # C=51: Active amperage L2
185  # D=7: Instantaneous value
186  # E=0: Total
188  key="1-0:51.7.0*255",
189  translation_key="l2_active_instantaneous_amperage",
190  state_class=SensorStateClass.MEASUREMENT,
191  device_class=SensorDeviceClass.CURRENT,
192  ),
193  # C=52: Active voltage L2
194  # D=7: Instantaneous value
195  # E=0: Total
197  key="1-0:52.7.0*255",
198  translation_key="l2_active_instantaneous_voltage",
199  state_class=SensorStateClass.MEASUREMENT,
200  device_class=SensorDeviceClass.VOLTAGE,
201  ),
202  # C=56: Active power L2
203  # D=7: Instantaneous value
204  # E=0: Total
206  key="1-0:56.7.0*255",
207  translation_key="l2_active_instantaneous_power",
208  state_class=SensorStateClass.MEASUREMENT,
209  device_class=SensorDeviceClass.POWER,
210  ),
211  # C=71: Active amperage L3
212  # D=7: Instantaneous value
213  # E=0: Total
215  key="1-0:71.7.0*255",
216  translation_key="l3_active_instantaneous_amperage",
217  state_class=SensorStateClass.MEASUREMENT,
218  device_class=SensorDeviceClass.CURRENT,
219  ),
220  # C=72: Active voltage L3
221  # D=7: Instantaneous value
222  # E=0: Total
224  key="1-0:72.7.0*255",
225  translation_key="l3_active_instantaneous_voltage",
226  state_class=SensorStateClass.MEASUREMENT,
227  device_class=SensorDeviceClass.VOLTAGE,
228  ),
229  # C=76: Active power L3
230  # D=7: Instantaneous value
231  # E=0: Total
233  key="1-0:76.7.0*255",
234  translation_key="l3_active_instantaneous_power",
235  state_class=SensorStateClass.MEASUREMENT,
236  device_class=SensorDeviceClass.POWER,
237  ),
238  # C=81: Angles
239  # D=7: Instantaneous value
240  # E=1: U(L2) x U(L1)
241  # E=2: U(L3) x U(L1)
242  # E=4: U(L1) x I(L1)
243  # E=15: U(L2) x I(L2)
244  # E=26: U(L3) x I(L3)
246  key="1-0:81.7.1*255",
247  translation_key="u_l2_u_l1_phase_angle",
248  ),
250  key="1-0:81.7.2*255",
251  translation_key="u_l3_u_l1_phase_angle",
252  ),
254  key="1-0:81.7.4*255",
255  translation_key="u_l1_i_l1_phase_angle",
256  ),
258  key="1-0:81.7.15*255",
259  translation_key="u_l2_i_l2_phase_angle",
260  ),
262  key="1-0:81.7.26*255",
263  translation_key="u_l3_i_l3_phase_angle",
264  ),
265  # C=96: Electricity-related service entries
267  key="1-0:96.1.0*255",
268  translation_key="metering_point_id_1",
269  ),
271  key="1-0:96.5.0*255",
272  translation_key="internal_operating_status",
273  ),
274 )
275 
276 SENSORS = {desc.key: desc for desc in SENSOR_TYPES}
277 
278 SENSOR_UNIT_MAPPING = {
279  "Wh": UnitOfEnergy.WATT_HOUR,
280  "kWh": UnitOfEnergy.KILO_WATT_HOUR,
281  "W": UnitOfPower.WATT,
282  "A": UnitOfElectricCurrent.AMPERE,
283  "V": UnitOfElectricPotential.VOLT,
284  "°": DEGREE,
285  "Hz": UnitOfFrequency.HERTZ,
286 }
287 
288 
290  hass: HomeAssistant,
291  config_entry: ConfigEntry,
292  async_add_entities: AddEntitiesCallback,
293 ) -> None:
294  """Set up the EDL21 sensor."""
295  hass.data[DOMAIN] = EDL21(hass, config_entry.data, async_add_entities)
296  await hass.data[DOMAIN].connect()
297 
298 
299 class EDL21:
300  """EDL21 handles telegrams sent by a compatible smart meter."""
301 
302  _OBIS_BLACKLIST = {
303  # C=96: Electricity-related service entries
304  "1-0:96.50.1*1", # Manufacturer specific EFR SGM-C4 Hardware version
305  "1-0:96.50.1*4", # Manufacturer specific EFR SGM-C4 Hardware version
306  "1-0:96.50.4*4", # Manufacturer specific EFR SGM-C4 Parameters version
307  "1-0:96.90.2*1", # Manufacturer specific EFR SGM-C4 Firmware Checksum
308  "1-0:96.90.2*2", # Manufacturer specific EFR SGM-C4 Firmware Checksum
309  # C=97: Electricity-related service entries
310  "1-0:97.97.0*0", # Manufacturer specific EFR SGM-C4 Error register
311  # A=129: Manufacturer specific
312  "129-129:199.130.3*255", # Iskraemeco: Manufacturer
313  "129-129:199.130.5*255", # Iskraemeco: Public Key
314  }
315 
316  def __init__(
317  self,
318  hass: HomeAssistant,
319  config: Mapping[str, Any],
320  async_add_entities: AddEntitiesCallback,
321  ) -> None:
322  """Initialize an EDL21 object."""
323  self._registered_obis: set[tuple[str, str]] = set()
324  self._hass_hass = hass
325  self._async_add_entities_async_add_entities = async_add_entities
326  self._serial_port_serial_port = config[CONF_SERIAL_PORT]
327  self._proto_proto = SmlProtocol(config[CONF_SERIAL_PORT])
328  self._proto_proto.add_listener(self.eventevent, ["SmlGetListResponse"])
329  LOGGER.debug(
330  "Initialized EDL21 on %s",
331  config[CONF_SERIAL_PORT],
332  )
333 
334  async def connect(self) -> None:
335  """Connect to an EDL21 reader."""
336  await self._proto_proto.connect(self._hass_hass.loop)
337 
338  def event(self, message_body) -> None:
339  """Handle events from pysml."""
340  assert isinstance(message_body, SmlGetListResponse)
341  LOGGER.debug("Received sml message on %s: %s", self._serial_port_serial_port, message_body)
342 
343  electricity_id = message_body["serverId"]
344 
345  if electricity_id is None:
346  LOGGER.debug(
347  "No electricity id found in sml message on %s", self._serial_port_serial_port
348  )
349  return
350  electricity_id = electricity_id.replace(" ", "")
351 
352  new_entities: list[EDL21Entity] = []
353  for telegram in message_body.get("valList", []):
354  if not (obis := telegram.get("objName")):
355  continue
356 
357  if (electricity_id, obis) in self._registered_obis:
359  self._hass_hass, SIGNAL_EDL21_TELEGRAM, electricity_id, telegram
360  )
361  else:
362  entity_description = SENSORS.get(obis)
363  if entity_description:
364  new_entities.append(
365  EDL21Entity(
366  electricity_id,
367  obis,
368  entity_description,
369  telegram,
370  )
371  )
372  self._registered_obis.add((electricity_id, obis))
373  elif obis not in self._OBIS_BLACKLIST_OBIS_BLACKLIST:
374  LOGGER.warning(
375  "Unhandled sensor %s detected. Please report at %s",
376  obis,
377  "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+edl21%22",
378  )
379  self._OBIS_BLACKLIST_OBIS_BLACKLIST.add(obis)
380 
381  if new_entities:
382  self._async_add_entities_async_add_entities(new_entities, update_before_add=True)
383 
384 
386  """Entity reading values from EDL21 telegram."""
387 
388  _attr_should_poll = False
389  _attr_has_entity_name = True
390 
391  def __init__(self, electricity_id, obis, entity_description, telegram):
392  """Initialize an EDL21Entity."""
393  self._electricity_id_electricity_id = electricity_id
394  self._obis_obis = obis
395  self._telegram_telegram = telegram
396  self._min_time_min_time = MIN_TIME_BETWEEN_UPDATES
397  self._last_update_last_update = utcnow()
398  self._async_remove_dispatcher_async_remove_dispatcher = None
399  self.entity_descriptionentity_description = entity_description
400  self._attr_unique_id_attr_unique_id = f"{electricity_id}_{obis}"
401  self._attr_device_info_attr_device_info = DeviceInfo(
402  identifiers={(DOMAIN, self._electricity_id_electricity_id)},
403  name=DEFAULT_DEVICE_NAME,
404  )
405 
406  async def async_added_to_hass(self) -> None:
407  """Run when entity about to be added to hass."""
408 
409  @callback
410  def handle_telegram(electricity_id, telegram):
411  """Update attributes from last received telegram for this object."""
412  if self._electricity_id_electricity_id != electricity_id:
413  return
414  if self._obis_obis != telegram.get("objName"):
415  return
416  if self._telegram_telegram == telegram:
417  return
418 
419  now = utcnow()
420  if now - self._last_update_last_update < self._min_time_min_time:
421  return
422 
423  self._telegram_telegram = telegram
424  self._last_update_last_update = now
425  self.async_write_ha_stateasync_write_ha_state()
426 
427  self._async_remove_dispatcher_async_remove_dispatcher = async_dispatcher_connect(
428  self.hasshass, SIGNAL_EDL21_TELEGRAM, handle_telegram
429  )
430 
431  async def async_will_remove_from_hass(self) -> None:
432  """Run when entity will be removed from hass."""
433  if self._async_remove_dispatcher_async_remove_dispatcher:
434  self._async_remove_dispatcher_async_remove_dispatcher()
435 
436  @property
437  def native_value(self) -> str:
438  """Return the value of the last received telegram."""
439  return self._telegram_telegram.get("value")
440 
441  @property
442  def native_unit_of_measurement(self) -> str | None:
443  """Return the unit of measurement."""
444  if (unit := self._telegram_telegram.get("unit")) is None or unit == 0:
445  return None
446 
447  return SENSOR_UNIT_MAPPING[unit]
def __init__(self, electricity_id, obis, entity_description, telegram)
Definition: sensor.py:391
None event(self, message_body)
Definition: sensor.py:338
None __init__(self, HomeAssistant hass, Mapping[str, Any] config, AddEntitiesCallback async_add_entities)
Definition: sensor.py:321
bool add(self, _T matcher)
Definition: match.py:185
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: sensor.py:293
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193