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:
Johan Bloemberg 2016-11-23 08:03:39 +01:00 committed by Paulus Schoutsen
parent bb46009efa
commit 64cfc4ff02
3 changed files with 246 additions and 0 deletions

View 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

View file

@ -174,6 +174,9 @@ https://github.com/TheRealLink/pythinkingcleaner/archive/v0.0.2.zip#pythinkingcl
# homeassistant.components.alarm_control_panel.alarmdotcom
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
https://github.com/aparraga/braviarc/archive/0.3.6.zip#braviarc==0.3.6

View 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