From 64cfc4ff0283b2cca515377045a3c0ab9047fb6e Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Wed, 23 Nov 2016 08:03:39 +0100 Subject: [PATCH] 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 --- homeassistant/components/sensor/dsmr.py | 179 ++++++++++++++++++++++++ requirements_all.txt | 3 + tests/components/sensor/test_dsmr.py | 64 +++++++++ 3 files changed, 246 insertions(+) create mode 100644 homeassistant/components/sensor/dsmr.py create mode 100644 tests/components/sensor/test_dsmr.py diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py new file mode 100644 index 00000000000..eb8e5174b47 --- /dev/null +++ b/homeassistant/components/sensor/dsmr.py @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index c5257dd0dde..7d123c93b77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py new file mode 100644 index 00000000000..166a4af9657 --- /dev/null +++ b/tests/components/sensor/test_dsmr.py @@ -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