DSMR sensor (#4309)
* Initial implemenation of DSMR component. * Fix linting * Remove protocol V2.2 support until merged upstream. * Generate requirements using script. * Use updated dsmr-parser with protocol 2.2 support. * Add tests. * Isort and input validation. * Add entities for gas and actual meter reading. Error handling. Use Throttle. * Implement non-blocking serial reader. * Improve logging. * Merge entities into one, add icons, fix tests for asyncio. * Add error logging for serial reader. * Refactoring and documentation. - refactor asyncio reader task to make sure it stops with HA - document general principle of this component - refactor entity reading to be more clear - remove cruft from split entity implementation * Use `port` configuration key. * DSMR V2.2 seems to conflict in explaining which tariff is high and low. http://www.netbeheernederland.nl/themas/hotspot/hotspot-documenten/?dossierid=11010056&title=Slimme%20meter&onderdeel=Documenten > DSMR v2.2 Final P1 >> 6.1: table vs table note Meter Reading electricity delivered to client normal tariff) in 0,01 kWh - 1-0:1.8.1.255 Meter Reading electricity delivered to client (low tariff) in 0,01 kWh - 1-0:1.8.2.255 Note: Tariff code 1 is used for low tariff and tariff code 2 is used for normal tariff. * Refactor to use asyncio.Protocol instead of loop+queue. * Fix requirements * Close transport when HA stops. * Cleanup. * Include as dependency for testing (until merged upstream.) * Fix style. * Update setup.cfg
This commit is contained in:
parent
bb46009efa
commit
64cfc4ff02
3 changed files with 246 additions and 0 deletions
179
homeassistant/components/sensor/dsmr.py
Normal file
179
homeassistant/components/sensor/dsmr.py
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
"""
|
||||||
|
Support for Dutch Smart Meter Requirements.
|
||||||
|
|
||||||
|
Also known as: Smartmeter or P1 port.
|
||||||
|
|
||||||
|
For more details about this platform, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/sensor.dsmr/
|
||||||
|
|
||||||
|
Technical overview:
|
||||||
|
|
||||||
|
DSMR is a standard to which Dutch smartmeters must comply. It specifies that
|
||||||
|
the smartmeter must send out a 'telegram' every 10 seconds over a serial port.
|
||||||
|
|
||||||
|
The contents of this telegram differ between version but they generally consist
|
||||||
|
of lines with 'obis' (Object Identification System, a numerical ID for a value)
|
||||||
|
followed with the value and unit.
|
||||||
|
|
||||||
|
This module sets up a asynchronous reading loop using the `dsmr_parser` module
|
||||||
|
which waits for a complete telegram, parser it and puts it on an async queue as
|
||||||
|
a dictionary of `obis`/object mapping. The numeric value and unit of each value
|
||||||
|
can be read from the objects attributes. Because the `obis` are know for each
|
||||||
|
DSMR version the Entities for this component are create during bootstrap.
|
||||||
|
|
||||||
|
Another loop (DSMR class) is setup which reads the telegram queue,
|
||||||
|
stores/caches the latest telegram and notifies the Entities that the telegram
|
||||||
|
has been updated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||||
|
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
|
DOMAIN = 'dsmr'
|
||||||
|
|
||||||
|
REQUIREMENTS = [
|
||||||
|
'https://github.com/aequitas/dsmr_parser/archive/async_protocol.zip'
|
||||||
|
'#dsmr_parser==0.4'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Smart meter sends telegram every 10 seconds
|
||||||
|
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
|
||||||
|
|
||||||
|
CONF_DSMR_VERSION = 'dsmr_version'
|
||||||
|
DEFAULT_PORT = '/dev/ttyUSB0'
|
||||||
|
DEFAULT_DSMR_VERSION = '2.2'
|
||||||
|
|
||||||
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
|
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string,
|
||||||
|
vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All(
|
||||||
|
cv.string, vol.In(['4', '2.2'])),
|
||||||
|
})
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ICON_POWER = 'mdi:flash'
|
||||||
|
ICON_GAS = 'mdi:fire'
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||||
|
"""Setup DSMR sensors."""
|
||||||
|
# suppres logging
|
||||||
|
logging.getLogger('dsmr_parser').setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
from dsmr_parser import obis_references as obis
|
||||||
|
from dsmr_parser.protocol import create_dsmr_reader
|
||||||
|
|
||||||
|
dsmr_version = config[CONF_DSMR_VERSION]
|
||||||
|
|
||||||
|
# define list of name,obis mappings to generate entities
|
||||||
|
obis_mapping = [
|
||||||
|
['Power Consumption', obis.CURRENT_ELECTRICITY_USAGE],
|
||||||
|
['Power Production', obis.CURRENT_ELECTRICITY_DELIVERY],
|
||||||
|
['Power Tariff', obis.ELECTRICITY_ACTIVE_TARIFF],
|
||||||
|
['Power Consumption (low)', obis.ELECTRICITY_USED_TARIFF_1],
|
||||||
|
['Power Consumption (normal)', obis.ELECTRICITY_USED_TARIFF_2],
|
||||||
|
['Power Production (low)', obis.ELECTRICITY_DELIVERED_TARIFF_1],
|
||||||
|
['Power Production (normal)', obis.ELECTRICITY_DELIVERED_TARIFF_2],
|
||||||
|
]
|
||||||
|
# protocol version specific obis
|
||||||
|
if dsmr_version == '4':
|
||||||
|
obis_mapping.append(['Gas Consumption', obis.HOURLY_GAS_METER_READING])
|
||||||
|
else:
|
||||||
|
obis_mapping.append(['Gas Consumption', obis.GAS_METER_READING])
|
||||||
|
|
||||||
|
# generate device entities
|
||||||
|
devices = [DSMREntity(name, obis) for name, obis in obis_mapping]
|
||||||
|
|
||||||
|
# setup devices
|
||||||
|
yield from async_add_devices(devices)
|
||||||
|
|
||||||
|
def update_entities_telegram(telegram):
|
||||||
|
"""Update entities with latests telegram & trigger state update."""
|
||||||
|
# make all device entities aware of new telegram
|
||||||
|
for device in devices:
|
||||||
|
device.telegram = telegram
|
||||||
|
hass.async_add_job(device.async_update_ha_state)
|
||||||
|
|
||||||
|
# creates a asyncio.Protocol for reading DSMR telegrams from serial
|
||||||
|
# and calls update_entities_telegram to update entities on arrival
|
||||||
|
dsmr = create_dsmr_reader(config[CONF_PORT], config[CONF_DSMR_VERSION],
|
||||||
|
update_entities_telegram, loop=hass.loop)
|
||||||
|
|
||||||
|
# start DSMR asycnio.Protocol reader
|
||||||
|
transport, _ = yield from hass.loop.create_task(dsmr)
|
||||||
|
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, transport.close)
|
||||||
|
|
||||||
|
|
||||||
|
class DSMREntity(Entity):
|
||||||
|
"""Entity reading values from DSMR telegram."""
|
||||||
|
|
||||||
|
def __init__(self, name, obis):
|
||||||
|
""""Initialize entity."""
|
||||||
|
# human readable name
|
||||||
|
self._name = name
|
||||||
|
# DSMR spec. value identifier
|
||||||
|
self._obis = obis
|
||||||
|
self.telegram = {}
|
||||||
|
|
||||||
|
def get_dsmr_object_attr(self, attribute):
|
||||||
|
"""Read attribute from last received telegram for this DSMR object."""
|
||||||
|
# make sure telegram contains an object for this entities obis
|
||||||
|
if self._obis not in self.telegram:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# get the attibute value if the object has it
|
||||||
|
dsmr_object = self.telegram[self._obis]
|
||||||
|
return getattr(dsmr_object, attribute, None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Return the name of the sensor."""
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self):
|
||||||
|
"""Icon to use in the frontend, if any."""
|
||||||
|
if 'Power' in self._name:
|
||||||
|
return ICON_POWER
|
||||||
|
elif 'Gas' in self._name:
|
||||||
|
return ICON_GAS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
"""Return the state of sensor, if available, translate if needed."""
|
||||||
|
from dsmr_parser import obis_references as obis
|
||||||
|
|
||||||
|
value = self.get_dsmr_object_attr('value')
|
||||||
|
|
||||||
|
if self._obis == obis.ELECTRICITY_ACTIVE_TARIFF:
|
||||||
|
return self.translate_tariff(value)
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit_of_measurement(self):
|
||||||
|
"""Return the unit of measurement of this entity, if any."""
|
||||||
|
return self.get_dsmr_object_attr('unit')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def translate_tariff(value):
|
||||||
|
"""Convert 2/1 to normal/low."""
|
||||||
|
# DSMR V2.2: Note: Tariff code 1 is used for low tariff
|
||||||
|
# and tariff code 2 is used for normal tariff.
|
||||||
|
|
||||||
|
if value == '0002':
|
||||||
|
return 'normal'
|
||||||
|
elif value == '0001':
|
||||||
|
return 'low'
|
||||||
|
else:
|
||||||
|
return None
|
|
@ -174,6 +174,9 @@ https://github.com/TheRealLink/pythinkingcleaner/archive/v0.0.2.zip#pythinkingcl
|
||||||
# homeassistant.components.alarm_control_panel.alarmdotcom
|
# homeassistant.components.alarm_control_panel.alarmdotcom
|
||||||
https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1
|
https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1
|
||||||
|
|
||||||
|
# homeassistant.components.sensor.dsmr
|
||||||
|
https://github.com/aequitas/dsmr_parser/archive/async_protocol.zip#dsmr_parser==0.4
|
||||||
|
|
||||||
# homeassistant.components.media_player.braviatv
|
# homeassistant.components.media_player.braviatv
|
||||||
https://github.com/aparraga/braviarc/archive/0.3.6.zip#braviarc==0.3.6
|
https://github.com/aparraga/braviarc/archive/0.3.6.zip#braviarc==0.3.6
|
||||||
|
|
||||||
|
|
64
tests/components/sensor/test_dsmr.py
Normal file
64
tests/components/sensor/test_dsmr.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
"""Test for DSMR components.
|
||||||
|
|
||||||
|
Tests setup of the DSMR component and ensure incoming telegrams cause Entity
|
||||||
|
to be updated with new values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from homeassistant.bootstrap import async_setup_component
|
||||||
|
from tests.common import assert_setup_component
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def test_default_setup(hass, monkeypatch):
|
||||||
|
"""Test the default setup."""
|
||||||
|
from dsmr_parser.obis_references import (
|
||||||
|
CURRENT_ELECTRICITY_USAGE,
|
||||||
|
ELECTRICITY_ACTIVE_TARIFF,
|
||||||
|
)
|
||||||
|
from dsmr_parser.objects import CosemObject
|
||||||
|
|
||||||
|
config = {'platform': 'dsmr'}
|
||||||
|
|
||||||
|
telegram = {
|
||||||
|
CURRENT_ELECTRICITY_USAGE: CosemObject([
|
||||||
|
{'value': Decimal('0.1'), 'unit': 'kWh'}
|
||||||
|
]),
|
||||||
|
ELECTRICITY_ACTIVE_TARIFF: CosemObject([
|
||||||
|
{'value': '0001', 'unit': ''}
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# mock for injecting DSMR telegram
|
||||||
|
dsmr = Mock(return_value=Mock())
|
||||||
|
monkeypatch.setattr('dsmr_parser.protocol.create_dsmr_reader', dsmr)
|
||||||
|
|
||||||
|
with assert_setup_component(1):
|
||||||
|
yield from async_setup_component(hass, 'sensor',
|
||||||
|
{'sensor': config})
|
||||||
|
|
||||||
|
telegram_callback = dsmr.call_args_list[0][0][2]
|
||||||
|
|
||||||
|
# make sure entities have been created and return 'unknown' state
|
||||||
|
power_consumption = hass.states.get('sensor.power_consumption')
|
||||||
|
assert power_consumption.state == 'unknown'
|
||||||
|
assert power_consumption.attributes.get('unit_of_measurement') is None
|
||||||
|
|
||||||
|
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
|
||||||
|
telegram_callback(telegram)
|
||||||
|
|
||||||
|
# after receiving telegram entities need to have the chance to update
|
||||||
|
yield from asyncio.sleep(0, loop=hass.loop)
|
||||||
|
|
||||||
|
# ensure entities have new state value after incoming telegram
|
||||||
|
power_consumption = hass.states.get('sensor.power_consumption')
|
||||||
|
assert power_consumption.state == '0.1'
|
||||||
|
assert power_consumption.attributes.get('unit_of_measurement') is 'kWh'
|
||||||
|
|
||||||
|
# tariff should be translated in human readable and have no unit
|
||||||
|
power_tariff = hass.states.get('sensor.power_tariff')
|
||||||
|
assert power_tariff.state == 'low'
|
||||||
|
assert power_tariff.attributes.get('unit_of_measurement') is None
|
Loading…
Add table
Add a link
Reference in a new issue