From 3c1fd1563a4add81ca0beb587ba8af85ae76f7d2 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Tue, 8 Nov 2016 10:38:21 +0100 Subject: [PATCH 01/22] Initial implemenation of DSMR component. --- homeassistant/components/sensor/dsmr.py | 146 ++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 homeassistant/components/sensor/dsmr.py diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py new file mode 100644 index 0000000000000..c51ae9eba565a --- /dev/null +++ b/homeassistant/components/sensor/dsmr.py @@ -0,0 +1,146 @@ +""" +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/ +""" + +import logging +import asyncio + +from homeassistant.helpers.entity import Entity +from homeassistant.const import CONF_DEVICE + +DOMAIN = 'dsmr' + +REQUIREMENTS = ['dsmr_parser'] + +DEFAULT_DEVICE = '/dev/ttyUSB0' +DEFAULT_DSMR_VERSION = '2.2' + +CONF_DSMR_VERSION = 'dsmr_version' + +log = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + from dsmr_parser.obis_references import ( + CURRENT_ELECTRICITY_USAGE, + CURRENT_ELECTRICITY_DELIVERY, + ELECTRICITY_ACTIVE_TARIFF + ) + + devices = [] + + dsmr = DSMR(hass, config, devices) + + devices += [ + DSMREntity('Power Usage', CURRENT_ELECTRICITY_USAGE, dsmr), + DSMREntity('Power Production', CURRENT_ELECTRICITY_DELIVERY, dsmr), + DSMRTariff('Power Tariff', ELECTRICITY_ACTIVE_TARIFF, dsmr), + ] + yield from async_add_devices(devices, True) + + yield from dsmr.async_update() + + +class DSMR: + """DSMR interface.""" + + def __init__(self, hass, config, devices): + """Setup DSMR serial interface and add device entities.""" + + from dsmr_parser.serial import ( + SERIAL_SETTINGS_V2_2, + SERIAL_SETTINGS_V4, + SerialReader + ) + from dsmr_parser import telegram_specifications + + dsmr_versions = { + '2.2': (SERIAL_SETTINGS_V2_2, telegram_specifications.V2_2), + '4': (SERIAL_SETTINGS_V4, telegram_specifications.V4), + } + + device = config.get(CONF_DEVICE, DEFAULT_DEVICE) + dsmr_version = config.get(CONF_DSMR_VERSION, DEFAULT_DSMR_VERSION) + + self.dsmr_parser = SerialReader( + device=device, + serial_settings=dsmr_versions[dsmr_version][0], + telegram_specification=dsmr_versions[dsmr_version][1], + ) + + self.hass = hass + self.devices = devices + self._telegram = {} + + @asyncio.coroutine + def async_update(self): + """Wait for DSMR telegram to be received and parsed.""" + + while True: + if hasattr(self.dsmr_parser, 'serial_handle'): + print(self.dsmr_parser.serial_handle.in_waiting) + log.info('retrieving new telegram') + self._telegram = next(self.dsmr_parser.read()) + log.info('got new telegram') + yield from asyncio.sleep(10) + + tasks = [] + for device in self.devices: + tasks.append(device.async_update_ha_state()) + + yield from asyncio.gather(*tasks, loop=self.hass.loop) + + @property + def telegram(self): + """Return latest received telegram.""" + + return self._telegram + + +class DSMREntity(Entity): + """Entity reading values from DSMR telegram.""" + + def __init__(self, name, obis, interface): + self._name = name + self._obis = obis + self._interface = interface + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return getattr(self._interface.telegram.get(self._obis, {}), + 'value', None) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return getattr(self._interface.telegram.get(self._obis, {}), + 'unit', None) + + +class DSMRTariff(DSMREntity): + """Convert integer tariff value to text.""" + + @property + def state(self): + """Convert 2/1 to high/low.""" + + tariff = super().state + print(type(tariff), tariff) + if tariff == '0002': + return 'high' + elif tariff == '0001': + return 'low' + else: + return None From 5b3f0ba9fe7b7d7268a045a00058e1e84e302e00 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Tue, 8 Nov 2016 10:52:40 +0100 Subject: [PATCH 02/22] Fix linting --- homeassistant/components/sensor/dsmr.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index c51ae9eba565a..692f18f8ae144 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -22,11 +22,12 @@ CONF_DSMR_VERSION = 'dsmr_version' -log = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup DSMR sensors.""" from dsmr_parser.obis_references import ( CURRENT_ELECTRICITY_USAGE, CURRENT_ELECTRICITY_DELIVERY, @@ -52,7 +53,6 @@ class DSMR: def __init__(self, hass, config, devices): """Setup DSMR serial interface and add device entities.""" - from dsmr_parser.serial import ( SERIAL_SETTINGS_V2_2, SERIAL_SETTINGS_V4, @@ -81,13 +81,10 @@ def __init__(self, hass, config, devices): @asyncio.coroutine def async_update(self): """Wait for DSMR telegram to be received and parsed.""" - while True: - if hasattr(self.dsmr_parser, 'serial_handle'): - print(self.dsmr_parser.serial_handle.in_waiting) - log.info('retrieving new telegram') + _LOGGER.info('retrieving new telegram') self._telegram = next(self.dsmr_parser.read()) - log.info('got new telegram') + _LOGGER.info('got new telegram') yield from asyncio.sleep(10) tasks = [] @@ -99,7 +96,6 @@ def async_update(self): @property def telegram(self): """Return latest received telegram.""" - return self._telegram @@ -107,6 +103,7 @@ class DSMREntity(Entity): """Entity reading values from DSMR telegram.""" def __init__(self, name, obis, interface): + """"Initialize entity.""" self._name = name self._obis = obis self._interface = interface @@ -135,7 +132,6 @@ class DSMRTariff(DSMREntity): @property def state(self): """Convert 2/1 to high/low.""" - tariff = super().state print(type(tariff), tariff) if tariff == '0002': From aed762776c17957377a3a7027074e0d084fa0011 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Tue, 8 Nov 2016 12:31:23 +0100 Subject: [PATCH 03/22] Remove protocol V2.2 support until merged upstream. --- homeassistant/components/sensor/dsmr.py | 4 +--- requirements_all.txt | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 692f18f8ae144..3cea0375e8389 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -18,7 +18,7 @@ REQUIREMENTS = ['dsmr_parser'] DEFAULT_DEVICE = '/dev/ttyUSB0' -DEFAULT_DSMR_VERSION = '2.2' +DEFAULT_DSMR_VERSION = '4' CONF_DSMR_VERSION = 'dsmr_version' @@ -54,14 +54,12 @@ class DSMR: def __init__(self, hass, config, devices): """Setup DSMR serial interface and add device entities.""" from dsmr_parser.serial import ( - SERIAL_SETTINGS_V2_2, SERIAL_SETTINGS_V4, SerialReader ) from dsmr_parser import telegram_specifications dsmr_versions = { - '2.2': (SERIAL_SETTINGS_V2_2, telegram_specifications.V2_2), '4': (SERIAL_SETTINGS_V4, telegram_specifications.V4), } diff --git a/requirements_all.txt b/requirements_all.txt index 01b297c8c030c..ac0e6ee0535f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -572,3 +572,6 @@ yahooweather==0.8 # homeassistant.components.zeroconf zeroconf==0.17.6 + +# homeassistant.components.sensor.dsmr +dsmr-parser==0.1 From 45446857fe74c1b66781d7f74e1cccbf74c13abe Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Tue, 8 Nov 2016 12:51:05 +0100 Subject: [PATCH 04/22] Generate requirements using script. --- requirements_all.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index ac0e6ee0535f0..60ee5f9f52034 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -81,6 +81,9 @@ dnspython3==1.15.0 # homeassistant.components.sensor.dovado dovado==0.1.15 +# homeassistant.components.sensor.dsmr +dsmr_parser + # homeassistant.components.dweet # homeassistant.components.sensor.dweet dweepy==0.2.0 @@ -572,6 +575,3 @@ yahooweather==0.8 # homeassistant.components.zeroconf zeroconf==0.17.6 - -# homeassistant.components.sensor.dsmr -dsmr-parser==0.1 From ce70497a340325bb80cda314e002c2c296aacd92 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Tue, 8 Nov 2016 22:37:34 +0100 Subject: [PATCH 05/22] Use updated dsmr-parser with protocol 2.2 support. --- homeassistant/components/sensor/dsmr.py | 6 +++--- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 3cea0375e8389..04642028055ac 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -15,7 +15,7 @@ DOMAIN = 'dsmr' -REQUIREMENTS = ['dsmr_parser'] +REQUIREMENTS = ['dsmr-parser==0.2'] DEFAULT_DEVICE = '/dev/ttyUSB0' DEFAULT_DSMR_VERSION = '4' @@ -44,7 +44,6 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): DSMRTariff('Power Tariff', ELECTRICITY_ACTIVE_TARIFF, dsmr), ] yield from async_add_devices(devices, True) - yield from dsmr.async_update() @@ -55,12 +54,14 @@ def __init__(self, hass, config, devices): """Setup DSMR serial interface and add device entities.""" from dsmr_parser.serial import ( SERIAL_SETTINGS_V4, + SERIAL_SETTINGS_V2_2, SerialReader ) from dsmr_parser import telegram_specifications dsmr_versions = { '4': (SERIAL_SETTINGS_V4, telegram_specifications.V4), + '2.2': (SERIAL_SETTINGS_V2_2, telegram_specifications.V2_2), } device = config.get(CONF_DEVICE, DEFAULT_DEVICE) @@ -131,7 +132,6 @@ class DSMRTariff(DSMREntity): def state(self): """Convert 2/1 to high/low.""" tariff = super().state - print(type(tariff), tariff) if tariff == '0002': return 'high' elif tariff == '0001': diff --git a/requirements_all.txt b/requirements_all.txt index 60ee5f9f52034..5299418e58aad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -82,7 +82,7 @@ dnspython3==1.15.0 dovado==0.1.15 # homeassistant.components.sensor.dsmr -dsmr_parser +dsmr-parser==0.2 # homeassistant.components.dweet # homeassistant.components.sensor.dweet From fc6e7321278b790a480bd9ad082daaf8e37b881e Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Tue, 8 Nov 2016 22:38:22 +0100 Subject: [PATCH 06/22] Add tests. --- homeassistant/components/sensor/dsmr.py | 23 ++++++++------ tests/components/sensor/test_dsmr.py | 42 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 tests/components/sensor/test_dsmr.py diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 04642028055ac..ad05c344679c8 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -80,17 +80,22 @@ def __init__(self, hass, config, devices): @asyncio.coroutine def async_update(self): """Wait for DSMR telegram to be received and parsed.""" - while True: - _LOGGER.info('retrieving new telegram') - self._telegram = next(self.dsmr_parser.read()) - _LOGGER.info('got new telegram') - yield from asyncio.sleep(10) + _LOGGER.info('retrieving new telegram') - tasks = [] - for device in self.devices: - tasks.append(device.async_update_ha_state()) + self._telegram = self.read_telegram() - yield from asyncio.gather(*tasks, loop=self.hass.loop) + _LOGGER.info('got new telegram') + + yield from asyncio.sleep(10, loop=self.hass.loop) + tasks = [] + for device in self.devices: + tasks.append(device.async_update_ha_state()) + + yield from asyncio.gather(*tasks, loop=self.hass.loop) + + def read_telegram(self): + """Read telegram.""" + return next(self.dsmr_parser.read()) @property def telegram(self): diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py new file mode 100644 index 0000000000000..7bc5cedcb60d3 --- /dev/null +++ b/tests/components/sensor/test_dsmr.py @@ -0,0 +1,42 @@ +"""Test for DSMR components.""" + +import asyncio +from homeassistant.bootstrap import async_setup_component +from unittest.mock import patch +from tests.common import assert_setup_component +from decimal import Decimal + + +@asyncio.coroutine +def test_default_setup(hass): + """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': ''} + ]), + } + + with patch('homeassistant.components.sensor.dsmr.DSMR.read_telegram', + return_value=telegram), assert_setup_component(1): + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + state = hass.states.get('sensor.power_usage') + + assert state.state == '0.1' + assert state.attributes.get('unit_of_measurement') is 'kWh' + + state = hass.states.get('sensor.power_tariff') + + assert state.state == 'low' + assert state.attributes.get('unit_of_measurement') is None From 9c86d0a845d65fff7ab5b6ac1dd03f76d5bca88a Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Tue, 8 Nov 2016 23:02:06 +0100 Subject: [PATCH 07/22] Isort and input validation. --- homeassistant/components/sensor/dsmr.py | 18 +++++++++++++----- tests/components/sensor/test_dsmr.py | 5 +++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index ad05c344679c8..2538d3bd1a9a9 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -7,20 +7,28 @@ https://home-assistant.io/components/sensor.dsmr/ """ -import logging import asyncio +import logging -from homeassistant.helpers.entity import Entity +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_DEVICE +from homeassistant.helpers.entity import Entity DOMAIN = 'dsmr' REQUIREMENTS = ['dsmr-parser==0.2'] +CONF_DSMR_VERSION = 'dsmr_version' DEFAULT_DEVICE = '/dev/ttyUSB0' DEFAULT_DSMR_VERSION = '4' -CONF_DSMR_VERSION = 'dsmr_version' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, + vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( + cv.string, vol.In(['4', '2.2'])), +}) _LOGGER = logging.getLogger(__name__) @@ -64,8 +72,8 @@ def __init__(self, hass, config, devices): '2.2': (SERIAL_SETTINGS_V2_2, telegram_specifications.V2_2), } - device = config.get(CONF_DEVICE, DEFAULT_DEVICE) - dsmr_version = config.get(CONF_DSMR_VERSION, DEFAULT_DSMR_VERSION) + device = config[CONF_DEVICE] + dsmr_version = config[CONF_DSMR_VERSION] self.dsmr_parser = SerialReader( device=device, diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index 7bc5cedcb60d3..a2b1523c85026 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -1,10 +1,11 @@ """Test for DSMR components.""" import asyncio -from homeassistant.bootstrap import async_setup_component +from decimal import Decimal from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component from tests.common import assert_setup_component -from decimal import Decimal @asyncio.coroutine From e4594f1ddb7705181e0dcd21c782bf25d0a53f79 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Thu, 10 Nov 2016 20:25:01 +0100 Subject: [PATCH 08/22] Add entities for gas and actual meter reading. Error handling. Use Throttle. --- homeassistant/components/sensor/dsmr.py | 58 +++++++++++++++---------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 2538d3bd1a9a9..2e275f484ef2a 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -7,19 +7,23 @@ https://home-assistant.io/components/sensor.dsmr/ """ -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_DEVICE from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle DOMAIN = 'dsmr' REQUIREMENTS = ['dsmr-parser==0.2'] +# Smart meter sends telegram every 10 seconds +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + CONF_DSMR_VERSION = 'dsmr_version' DEFAULT_DEVICE = '/dev/ttyUSB0' DEFAULT_DSMR_VERSION = '4' @@ -36,23 +40,28 @@ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup DSMR sensors.""" - from dsmr_parser.obis_references import ( - CURRENT_ELECTRICITY_USAGE, - CURRENT_ELECTRICITY_DELIVERY, - ELECTRICITY_ACTIVE_TARIFF - ) + from dsmr_parser import obis_references as obis devices = [] dsmr = DSMR(hass, config, devices) devices += [ - DSMREntity('Power Usage', CURRENT_ELECTRICITY_USAGE, dsmr), - DSMREntity('Power Production', CURRENT_ELECTRICITY_DELIVERY, dsmr), - DSMRTariff('Power Tariff', ELECTRICITY_ACTIVE_TARIFF, dsmr), + DSMREntity('Power Consumption', obis.CURRENT_ELECTRICITY_USAGE, dsmr), + DSMREntity('Power Production', obis.CURRENT_ELECTRICITY_DELIVERY, dsmr), + DSMRTariff('Power Tariff', obis.ELECTRICITY_ACTIVE_TARIFF, dsmr), + DSMREntity('Power Consumption (normal)', obis.ELECTRICITY_USED_TARIFF_1, dsmr), + DSMREntity('Power Consumption (low)', obis.ELECTRICITY_USED_TARIFF_2, dsmr), + DSMREntity('Power Production (normal)', obis.ELECTRICITY_DELIVERED_TARIFF_1, dsmr), + DSMREntity('Power Production (low)', obis.ELECTRICITY_DELIVERED_TARIFF_1, dsmr), ] - yield from async_add_devices(devices, True) - yield from dsmr.async_update() + dsmr_version = config[CONF_DSMR_VERSION] + if dsmr_version == '4': + devices.append(DSMREntity('Gas Consumption', obis.HOURLY_GAS_METER_READING, dsmr)) + else: + devices.append(DSMREntity('Gas Consumption', obis.GAS_METER_READING, dsmr)) + + add_devices(devices) class DSMR: @@ -88,19 +97,16 @@ def __init__(self, hass, config, devices): @asyncio.coroutine def async_update(self): """Wait for DSMR telegram to be received and parsed.""" - _LOGGER.info('retrieving new telegram') - - self._telegram = self.read_telegram() - - _LOGGER.info('got new telegram') - yield from asyncio.sleep(10, loop=self.hass.loop) - tasks = [] - for device in self.devices: - tasks.append(device.async_update_ha_state()) - - yield from asyncio.gather(*tasks, loop=self.hass.loop) + _LOGGER.info('retrieving DSMR telegram') + try: + self._telegram = self.read_telegram() + except dsmr_parser.exceptions.ParseError: + _LOGGER.error('parse error, correct dsmr_version specified?') + except: + _LOGGER.exception('unexpected errur during telegram retrieval') + @Throttle(MIN_TIME_BETWEEN_UPDATES) def read_telegram(self): """Read telegram.""" return next(self.dsmr_parser.read()) @@ -143,10 +149,14 @@ class DSMRTariff(DSMREntity): @property def state(self): - """Convert 2/1 to high/low.""" + """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. + tariff = super().state if tariff == '0002': - return 'high' + return 'normal' elif tariff == '0001': return 'low' else: From 27a4e01c089d3e50d9c16c603ea19d4f912cd115 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Sat, 12 Nov 2016 00:06:19 +0100 Subject: [PATCH 09/22] Implement non-blocking serial reader. --- homeassistant/components/sensor/dsmr.py | 103 ++++++++++++++---------- requirements_all.txt | 2 +- tests/components/sensor/test_dsmr.py | 10 +-- 3 files changed, 65 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 2e275f484ef2a..00b4beed8559e 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -9,24 +9,23 @@ import logging from datetime import timedelta - +import asyncio import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_DEVICE from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle DOMAIN = 'dsmr' -REQUIREMENTS = ['dsmr-parser==0.2'] +REQUIREMENTS = ['dsmr-parser==0.3'] # Smart meter sends telegram every 10 seconds MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) CONF_DSMR_VERSION = 'dsmr_version' DEFAULT_DEVICE = '/dev/ttyUSB0' -DEFAULT_DSMR_VERSION = '4' +DEFAULT_DSMR_VERSION = '2.2' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, @@ -42,78 +41,92 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup DSMR sensors.""" from dsmr_parser import obis_references as obis - devices = [] - - dsmr = DSMR(hass, config, devices) + dsmr_version = config[CONF_DSMR_VERSION] - devices += [ - DSMREntity('Power Consumption', obis.CURRENT_ELECTRICITY_USAGE, dsmr), - DSMREntity('Power Production', obis.CURRENT_ELECTRICITY_DELIVERY, dsmr), - DSMRTariff('Power Tariff', obis.ELECTRICITY_ACTIVE_TARIFF, dsmr), - DSMREntity('Power Consumption (normal)', obis.ELECTRICITY_USED_TARIFF_1, dsmr), - DSMREntity('Power Consumption (low)', obis.ELECTRICITY_USED_TARIFF_2, dsmr), - DSMREntity('Power Production (normal)', obis.ELECTRICITY_DELIVERED_TARIFF_1, dsmr), - DSMREntity('Power Production (low)', obis.ELECTRICITY_DELIVERED_TARIFF_1, dsmr), + # 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 (normal)', obis.ELECTRICITY_USED_TARIFF_1], + ['Power Consumption (low)', obis.ELECTRICITY_USED_TARIFF_2], + ['Power Production (normal)', obis.ELECTRICITY_DELIVERED_TARIFF_1], + ['Power Production (low)', obis.ELECTRICITY_DELIVERED_TARIFF_1], ] - dsmr_version = config[CONF_DSMR_VERSION] + # protocol version specific obis if dsmr_version == '4': - devices.append(DSMREntity('Gas Consumption', obis.HOURLY_GAS_METER_READING, dsmr)) + obis_mapping.append(['Gas Consumption', obis.HOURLY_GAS_METER_READING]) else: - devices.append(DSMREntity('Gas Consumption', obis.GAS_METER_READING, dsmr)) + obis_mapping.append(['Gas Consumption', obis.GAS_METER_READING]) + + # make list available early to allow cross referencing dsmr/entities + devices = [] + + # create DSMR interface + dsmr = DSMR(hass, config, devices) + + # generate device entities + devices += [DSMREntity(name, obis, dsmr) for name, obis in obis_mapping] + + # setup devices + yield from hass.loop.create_task(async_add_devices(devices)) + + # queue for receiving parsed telegrams from async dsmr reader + queue = asyncio.Queue() + + # add asynchronous serial reader/parser task + hass.loop.create_task(dsmr.dsmr_parser.read(queue)) - add_devices(devices) + # add task to receive telegrams and update entities + hass.loop.create_task(dsmr.read_telegrams(queue)) class DSMR: """DSMR interface.""" def __init__(self, hass, config, devices): - """Setup DSMR serial interface and add device entities.""" + """Setup DSMR serial interface, initialize, add device entity list.""" from dsmr_parser.serial import ( SERIAL_SETTINGS_V4, SERIAL_SETTINGS_V2_2, - SerialReader + AsyncSerialReader ) from dsmr_parser import telegram_specifications + # map dsmr version to settings dsmr_versions = { '4': (SERIAL_SETTINGS_V4, telegram_specifications.V4), '2.2': (SERIAL_SETTINGS_V2_2, telegram_specifications.V2_2), } - device = config[CONF_DEVICE] + # initialize asynchronous telegram reader dsmr_version = config[CONF_DSMR_VERSION] - - self.dsmr_parser = SerialReader( - device=device, + self.dsmr_parser = AsyncSerialReader( + device=config[CONF_DEVICE], serial_settings=dsmr_versions[dsmr_version][0], telegram_specification=dsmr_versions[dsmr_version][1], ) - self.hass = hass + # keep list of device entities to update self.devices = devices + + # initialize empty telegram self._telegram = {} @asyncio.coroutine - def async_update(self): - """Wait for DSMR telegram to be received and parsed.""" - - _LOGGER.info('retrieving DSMR telegram') - try: - self._telegram = self.read_telegram() - except dsmr_parser.exceptions.ParseError: - _LOGGER.error('parse error, correct dsmr_version specified?') - except: - _LOGGER.exception('unexpected errur during telegram retrieval') - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def read_telegram(self): - """Read telegram.""" - return next(self.dsmr_parser.read()) + def read_telegrams(self, queue): + """Receive parsed telegram from DSMR reader, update entities.""" + while True: + # asynchronously get latest telegram when it arrives + self._telegram = yield from queue.get() + + # make all device entities aware of new telegram + for device in self.devices: + yield from device.async_update_ha_state() @property def telegram(self): - """Return latest received telegram.""" + """Return telegram object.""" return self._telegram @@ -122,8 +135,11 @@ class DSMREntity(Entity): def __init__(self, name, obis, interface): """"Initialize entity.""" + # human readable name self._name = name + # DSMR spec. value identifier self._obis = obis + # interface class to get telegram data self._interface = interface @property @@ -133,7 +149,7 @@ def name(self): @property def state(self): - """Return the state of the sensor.""" + """Return the state of the sensor, if available.""" return getattr(self._interface.telegram.get(self._obis, {}), 'value', None) @@ -150,7 +166,6 @@ class DSMRTariff(DSMREntity): @property def state(self): """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. diff --git a/requirements_all.txt b/requirements_all.txt index 5299418e58aad..b80a9fb8515bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -82,7 +82,7 @@ dnspython3==1.15.0 dovado==0.1.15 # homeassistant.components.sensor.dsmr -dsmr-parser==0.2 +dsmr-parser==0.3 # homeassistant.components.dweet # homeassistant.components.sensor.dweet diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index a2b1523c85026..125f22a8b176d 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -28,13 +28,13 @@ def test_default_setup(hass): ]), } - with patch('homeassistant.components.sensor.dsmr.DSMR.read_telegram', - return_value=telegram), assert_setup_component(1): - yield from async_setup_component(hass, 'sensor', {'sensor': config}) + # with patch('homeassistant.components.sensor.dsmr.DSMR.read_telegram', + # return_value=telegram), assert_setup_component(1): + yield from async_setup_component(hass, 'sensor', {'sensor': config}) - state = hass.states.get('sensor.power_usage') + state = hass.states.get('sensor.power_consumption') - assert state.state == '0.1' + assert state.state == 'unknown' assert state.attributes.get('unit_of_measurement') is 'kWh' state = hass.states.get('sensor.power_tariff') From 1cf1341ef12e2bb8e75c3104d6dc32f3c65a7049 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Sat, 12 Nov 2016 00:35:31 +0100 Subject: [PATCH 10/22] Improve logging. --- homeassistant/components/sensor/dsmr.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 00b4beed8559e..fdadecaa11d8a 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -39,6 +39,9 @@ @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 dsmr_version = config[CONF_DSMR_VERSION] @@ -119,6 +122,7 @@ def read_telegrams(self, queue): while True: # asynchronously get latest telegram when it arrives self._telegram = yield from queue.get() + _LOGGER.debug('received DSMR telegram') # make all device entities aware of new telegram for device in self.devices: From aabbe6ebad80c90a89ad73b32f685be37d045baf Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Sat, 12 Nov 2016 23:59:47 +0100 Subject: [PATCH 11/22] Merge entities into one, add icons, fix tests for asyncio. --- homeassistant/components/sensor/dsmr.py | 37 +++++++++++++++++++++++-- tests/components/sensor/test_dsmr.py | 35 +++++++++++++++-------- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index fdadecaa11d8a..f798ff3e7ce06 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -35,6 +35,9 @@ _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): @@ -151,11 +154,26 @@ 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 the sensor, if available.""" - return getattr(self._interface.telegram.get(self._obis, {}), - 'value', None) + """Return the state of sensor, if available, translate if needed.""" + from dsmr_parser import obis_references as obis + + value = getattr(self._interface.telegram.get(self._obis, {}), + 'value', None) + + if self._obis == obis.ELECTRICITY_ACTIVE_TARIFF: + return self.translate_tariff(value) + else: + return value @property def unit_of_measurement(self): @@ -163,6 +181,19 @@ def unit_of_measurement(self): return getattr(self._interface.telegram.get(self._obis, {}), 'unit', None) + @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 + class DSMRTariff(DSMREntity): """Convert integer tariff value to text.""" diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index 125f22a8b176d..831ad01dc5f2d 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -2,14 +2,13 @@ import asyncio from decimal import Decimal -from unittest.mock import patch from homeassistant.bootstrap import async_setup_component from tests.common import assert_setup_component @asyncio.coroutine -def test_default_setup(hass): +def test_default_setup(hass, monkeypatch): """Test the default setup.""" from dsmr_parser.obis_references import ( CURRENT_ELECTRICITY_USAGE, @@ -28,16 +27,30 @@ def test_default_setup(hass): ]), } - # with patch('homeassistant.components.sensor.dsmr.DSMR.read_telegram', - # return_value=telegram), assert_setup_component(1): - yield from async_setup_component(hass, 'sensor', {'sensor': config}) + # mock queue for injecting DSMR telegram + queue = asyncio.Queue(loop=hass.loop) + monkeypatch.setattr('asyncio.Queue', lambda: queue) - state = hass.states.get('sensor.power_consumption') + with assert_setup_component(1): + yield from async_setup_component(hass, 'sensor', {'sensor': config}) - assert state.state == 'unknown' - assert state.attributes.get('unit_of_measurement') is 'kWh' + # 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 - state = hass.states.get('sensor.power_tariff') + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + yield from queue.put(telegram) - assert state.state == 'low' - assert state.attributes.get('unit_of_measurement') is None + # 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 From c5de69080dba123c890e4e981e40255ba287d1fd Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Sun, 13 Nov 2016 00:12:34 +0100 Subject: [PATCH 12/22] Add error logging for serial reader. --- homeassistant/components/sensor/dsmr.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index f798ff3e7ce06..1fb2346d522e5 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -81,7 +81,15 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): queue = asyncio.Queue() # add asynchronous serial reader/parser task - hass.loop.create_task(dsmr.dsmr_parser.read(queue)) + reader = hass.loop.create_task(dsmr.dsmr_parser.read(queue)) + + # serial telegram reader is a infinite looping task, it will only resolve + # when it has an exception, in that case log this. + def handle_error(future): + """If result is an exception log it.""" + _LOGGER.error('error during initialization of DSMR serial reader: %s', + future.exception()) + reader.add_done_callback(handle_error) # add task to receive telegrams and update entities hass.loop.create_task(dsmr.read_telegrams(queue)) From 8f30b516a3327db69e6fb30736b20a5d048c84dc Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Sun, 13 Nov 2016 14:42:45 +0100 Subject: [PATCH 13/22] 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 --- homeassistant/components/sensor/dsmr.py | 89 ++++++++++++++++--------- tests/components/sensor/test_dsmr.py | 6 +- 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 1fb2346d522e5..1119b15139485 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -5,6 +5,25 @@ 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 logging @@ -13,7 +32,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_DEVICE +from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity DOMAIN = 'dsmr' @@ -68,17 +87,17 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # make list available early to allow cross referencing dsmr/entities devices = [] + # queue for receiving parsed telegrams from async dsmr reader + queue = asyncio.Queue() + # create DSMR interface - dsmr = DSMR(hass, config, devices) + dsmr = DSMR(hass, config, devices, queue) # generate device entities devices += [DSMREntity(name, obis, dsmr) for name, obis in obis_mapping] # setup devices - yield from hass.loop.create_task(async_add_devices(devices)) - - # queue for receiving parsed telegrams from async dsmr reader - queue = asyncio.Queue() + yield from async_add_devices(devices) # add asynchronous serial reader/parser task reader = hass.loop.create_task(dsmr.dsmr_parser.read(queue)) @@ -92,13 +111,13 @@ def handle_error(future): reader.add_done_callback(handle_error) # add task to receive telegrams and update entities - hass.loop.create_task(dsmr.read_telegrams(queue)) + hass.async_add_job(dsmr.read_telegrams) class DSMR: """DSMR interface.""" - def __init__(self, hass, config, devices): + def __init__(self, hass, config, devices, queue): """Setup DSMR serial interface, initialize, add device entity list.""" from dsmr_parser.serial import ( SERIAL_SETTINGS_V4, @@ -124,15 +143,28 @@ def __init__(self, hass, config, devices): # keep list of device entities to update self.devices = devices + self._queue = queue + # initialize empty telegram self._telegram = {} + # forward stop event to reading loop + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + self._queue.put_nowait) + @asyncio.coroutine - def read_telegrams(self, queue): + def read_telegrams(self): """Receive parsed telegram from DSMR reader, update entities.""" while True: # asynchronously get latest telegram when it arrives - self._telegram = yield from queue.get() + event = yield from self._queue.get() + + # stop loop if stop event was received + if getattr(event, 'event_type', None) == EVENT_HOMEASSISTANT_STOP: + self._queue.task_done() + return + + self._telegram = event _LOGGER.debug('received DSMR telegram') # make all device entities aware of new telegram @@ -157,6 +189,19 @@ def __init__(self, name, obis, interface): # interface class to get telegram data self._interface = interface + def get_dsmr_object_attr(self, attribute): + """Read attribute from last received telegram for this DSMR object.""" + # get most recent cached telegram from interface + telegram = self._interface.telegram + + # make sure telegram contains an object for this entities obis + if self._obis not in telegram: + return None + + # get the attibute value if the object has it + dsmr_object = telegram[self._obis] + return getattr(dsmr_object, attribute, None) + @property def name(self): """Return the name of the sensor.""" @@ -175,8 +220,7 @@ def state(self): """Return the state of sensor, if available, translate if needed.""" from dsmr_parser import obis_references as obis - value = getattr(self._interface.telegram.get(self._obis, {}), - 'value', None) + value = self.get_dsmr_object_attr('value') if self._obis == obis.ELECTRICITY_ACTIVE_TARIFF: return self.translate_tariff(value) @@ -186,8 +230,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return getattr(self._interface.telegram.get(self._obis, {}), - 'unit', None) + return self.get_dsmr_object_attr('unit') @staticmethod def translate_tariff(value): @@ -201,21 +244,3 @@ def translate_tariff(value): return 'low' else: return None - - -class DSMRTariff(DSMREntity): - """Convert integer tariff value to text.""" - - @property - def state(self): - """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. - - tariff = super().state - if tariff == '0002': - return 'normal' - elif tariff == '0001': - return 'low' - else: - return None diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index 831ad01dc5f2d..6003f0df3b197 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -1,4 +1,8 @@ -"""Test for DSMR components.""" +"""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 42d7e2364017c331de602e4b98d99ee032af46cf Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Sun, 13 Nov 2016 18:15:42 +0100 Subject: [PATCH 14/22] Use `port` configuration key. --- homeassistant/components/sensor/dsmr.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 1119b15139485..0293d0030bbac 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -32,7 +32,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity DOMAIN = 'dsmr' @@ -43,11 +43,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) CONF_DSMR_VERSION = 'dsmr_version' -DEFAULT_DEVICE = '/dev/ttyUSB0' +DEFAULT_PORT = '/dev/ttyUSB0' DEFAULT_DSMR_VERSION = '2.2' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, + 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'])), }) @@ -135,7 +135,7 @@ def __init__(self, hass, config, devices, queue): # initialize asynchronous telegram reader dsmr_version = config[CONF_DSMR_VERSION] self.dsmr_parser = AsyncSerialReader( - device=config[CONF_DEVICE], + device=config[CONF_PORT], serial_settings=dsmr_versions[dsmr_version][0], telegram_specification=dsmr_versions[dsmr_version][1], ) From 53673342bcc402e7a7b5a7c8187ee33574b52c5c Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Sun, 13 Nov 2016 22:38:14 +0100 Subject: [PATCH 15/22] 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. --- homeassistant/components/sensor/dsmr.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 0293d0030bbac..188731eca6408 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -73,10 +73,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): ['Power Consumption', obis.CURRENT_ELECTRICITY_USAGE], ['Power Production', obis.CURRENT_ELECTRICITY_DELIVERY], ['Power Tariff', obis.ELECTRICITY_ACTIVE_TARIFF], - ['Power Consumption (normal)', obis.ELECTRICITY_USED_TARIFF_1], - ['Power Consumption (low)', obis.ELECTRICITY_USED_TARIFF_2], - ['Power Production (normal)', obis.ELECTRICITY_DELIVERED_TARIFF_1], + ['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': From 873cbd40620c65754aa0d36345dad204ad5bd76f Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Mon, 21 Nov 2016 16:41:46 +0100 Subject: [PATCH 16/22] Refactor to use asyncio.Protocol instead of loop+queue. --- homeassistant/components/sensor/dsmr.py | 124 ++++++------------------ tests/components/sensor/test_dsmr.py | 14 ++- 2 files changed, 36 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 188731eca6408..ffea5ccff9396 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -26,18 +26,21 @@ has been updated. """ +import asyncio import logging from datetime import timedelta -import asyncio -import voluptuous as vol + import homeassistant.helpers.config_validation as cv +import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_PORT from homeassistant.helpers.entity import Entity DOMAIN = 'dsmr' -REQUIREMENTS = ['dsmr-parser==0.3'] +REQUIREMENTS = [ + 'https://github.com/aequitas/dsmr_parser/archive/async_protocol.zip' +] # Smart meter sends telegram every 10 seconds MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) @@ -65,6 +68,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 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] @@ -84,122 +88,48 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): else: obis_mapping.append(['Gas Consumption', obis.GAS_METER_READING]) - # make list available early to allow cross referencing dsmr/entities - devices = [] - - # queue for receiving parsed telegrams from async dsmr reader - queue = asyncio.Queue() - - # create DSMR interface - dsmr = DSMR(hass, config, devices, queue) - # generate device entities - devices += [DSMREntity(name, obis, dsmr) for name, obis in obis_mapping] + devices = [DSMREntity(name, obis) for name, obis in obis_mapping] # setup devices yield from async_add_devices(devices) - # add asynchronous serial reader/parser task - reader = hass.loop.create_task(dsmr.dsmr_parser.read(queue)) - - # serial telegram reader is a infinite looping task, it will only resolve - # when it has an exception, in that case log this. - def handle_error(future): - """If result is an exception log it.""" - _LOGGER.error('error during initialization of DSMR serial reader: %s', - future.exception()) - reader.add_done_callback(handle_error) - - # add task to receive telegrams and update entities - hass.async_add_job(dsmr.read_telegrams) - - -class DSMR: - """DSMR interface.""" - - def __init__(self, hass, config, devices, queue): - """Setup DSMR serial interface, initialize, add device entity list.""" - from dsmr_parser.serial import ( - SERIAL_SETTINGS_V4, - SERIAL_SETTINGS_V2_2, - AsyncSerialReader - ) - from dsmr_parser import telegram_specifications - - # map dsmr version to settings - dsmr_versions = { - '4': (SERIAL_SETTINGS_V4, telegram_specifications.V4), - '2.2': (SERIAL_SETTINGS_V2_2, telegram_specifications.V2_2), - } - - # initialize asynchronous telegram reader - dsmr_version = config[CONF_DSMR_VERSION] - self.dsmr_parser = AsyncSerialReader( - device=config[CONF_PORT], - serial_settings=dsmr_versions[dsmr_version][0], - telegram_specification=dsmr_versions[dsmr_version][1], - ) - - # keep list of device entities to update - self.devices = devices - - self._queue = queue - - # initialize empty telegram - self._telegram = {} - - # forward stop event to reading loop - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, - self._queue.put_nowait) - - @asyncio.coroutine - def read_telegrams(self): - """Receive parsed telegram from DSMR reader, update entities.""" - while True: - # asynchronously get latest telegram when it arrives - event = yield from self._queue.get() - - # stop loop if stop event was received - if getattr(event, 'event_type', None) == EVENT_HOMEASSISTANT_STOP: - self._queue.task_done() - return - - self._telegram = event - _LOGGER.debug('received DSMR telegram') - - # make all device entities aware of new telegram - for device in self.devices: - yield from device.async_update_ha_state() + def update_entities_telegram(telegram): + """Updates 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) + # hass.loop.create_task(device.async_update_ha_state()) - @property - def telegram(self): - """Return telegram object.""" - return self._telegram + # 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 + yield from hass.loop.create_task(dsmr) class DSMREntity(Entity): """Entity reading values from DSMR telegram.""" - def __init__(self, name, obis, interface): + def __init__(self, name, obis): """"Initialize entity.""" # human readable name self._name = name # DSMR spec. value identifier self._obis = obis - # interface class to get telegram data - self._interface = interface + self.telegram = {} def get_dsmr_object_attr(self, attribute): """Read attribute from last received telegram for this DSMR object.""" - # get most recent cached telegram from interface - telegram = self._interface.telegram - # make sure telegram contains an object for this entities obis - if self._obis not in telegram: + if self._obis not in self.telegram: return None # get the attibute value if the object has it - dsmr_object = telegram[self._obis] + dsmr_object = self.telegram[self._obis] return getattr(dsmr_object, attribute, None) @property diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index 6003f0df3b197..166a4af965709 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -6,6 +6,7 @@ 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 @@ -31,12 +32,15 @@ def test_default_setup(hass, monkeypatch): ]), } - # mock queue for injecting DSMR telegram - queue = asyncio.Queue(loop=hass.loop) - monkeypatch.setattr('asyncio.Queue', lambda: queue) + # 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}) + 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') @@ -44,7 +48,7 @@ def test_default_setup(hass, monkeypatch): assert power_consumption.attributes.get('unit_of_measurement') is None # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser - yield from queue.put(telegram) + telegram_callback(telegram) # after receiving telegram entities need to have the chance to update yield from asyncio.sleep(0, loop=hass.loop) From 749de0183a728bca108460d9219ef76edd0c8daa Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Mon, 21 Nov 2016 16:50:58 +0100 Subject: [PATCH 17/22] Fix requirements --- homeassistant/components/sensor/dsmr.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index ffea5ccff9396..85bebd9627073 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -40,6 +40,7 @@ REQUIREMENTS = [ 'https://github.com/aequitas/dsmr_parser/archive/async_protocol.zip' + '#dsmr_parser==0.4' ] # Smart meter sends telegram every 10 seconds From 30137d48cefdafb5b30dd373d3b9c3a483e82eca Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Mon, 21 Nov 2016 17:10:29 +0100 Subject: [PATCH 18/22] Close transport when HA stops. --- homeassistant/components/sensor/dsmr.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 85bebd9627073..0876b765b36b0 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -33,7 +33,7 @@ import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_PORT +from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity DOMAIN = 'dsmr' @@ -109,7 +109,9 @@ def update_entities_telegram(telegram): update_entities_telegram, loop=hass.loop) # start DSMR asycnio.Protocol reader - yield from hass.loop.create_task(dsmr) + transport, protocol = yield from hass.loop.create_task(dsmr) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, transport.close) class DSMREntity(Entity): From 95e5a83b1dccbec22dd8c73eee65c410abee3303 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Mon, 21 Nov 2016 17:11:40 +0100 Subject: [PATCH 19/22] Cleanup. --- homeassistant/components/sensor/dsmr.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 0876b765b36b0..4252abccf6cdf 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -101,7 +101,6 @@ def update_entities_telegram(telegram): for device in devices: device.telegram = telegram hass.async_add_job(device.async_update_ha_state) - # hass.loop.create_task(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 From eb6c853985e67c4fd8e1c471b4de83aa1c00bd9f Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Mon, 21 Nov 2016 17:26:02 +0100 Subject: [PATCH 20/22] Include as dependency for testing (until merged upstream.) --- requirements_all.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index b80a9fb8515bf..13e89f03ccf44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -81,9 +81,6 @@ dnspython3==1.15.0 # homeassistant.components.sensor.dovado dovado==0.1.15 -# homeassistant.components.sensor.dsmr -dsmr-parser==0.3 - # homeassistant.components.dweet # homeassistant.components.sensor.dweet dweepy==0.2.0 @@ -174,6 +171,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.5.zip#braviarc==0.3.5 From f5e9ccc8a6aaeac2d3356741d0a7286a85ce7a00 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Mon, 21 Nov 2016 17:49:55 +0100 Subject: [PATCH 21/22] Fix style. --- homeassistant/components/sensor/dsmr.py | 7 ++++--- setup.cfg | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 4252abccf6cdf..eb8e5174b47f6 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -30,8 +30,9 @@ import logging from datetime import timedelta -import homeassistant.helpers.config_validation as cv 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 @@ -96,7 +97,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): yield from async_add_devices(devices) def update_entities_telegram(telegram): - """Updates entities with latests telegram & trigger state update.""" + """Update entities with latests telegram & trigger state update.""" # make all device entities aware of new telegram for device in devices: device.telegram = telegram @@ -108,7 +109,7 @@ def update_entities_telegram(telegram): update_entities_telegram, loop=hass.loop) # start DSMR asycnio.Protocol reader - transport, protocol = yield from hass.loop.create_task(dsmr) + transport, _ = yield from hass.loop.create_task(dsmr) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, transport.close) diff --git a/setup.cfg b/setup.cfg index 6d952083a310d..0d5442074f1c5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,3 +10,10 @@ exclude = .venv,.git,.tox,docs,www_static,venv,bin,lib,deps,build [pydocstyle] match_dir = ^((?!\.|www_static).)*$ + +[isort] +multi_line_output = 4 +indent = " " +forced_separate = homeassistant,voluptuous,aiohttp +known_standard_library = typing +not_skip = __init__.py From 0762fca8621b2102d6fb46ad8114106a21e5694d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 22 Nov 2016 23:03:21 -0800 Subject: [PATCH 22/22] Update setup.cfg --- setup.cfg | 7 ------- 1 file changed, 7 deletions(-) diff --git a/setup.cfg b/setup.cfg index 0d5442074f1c5..6d952083a310d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,10 +10,3 @@ exclude = .venv,.git,.tox,docs,www_static,venv,bin,lib,deps,build [pydocstyle] match_dir = ^((?!\.|www_static).)*$ - -[isort] -multi_line_output = 4 -indent = " " -forced_separate = homeassistant,voluptuous,aiohttp -known_standard_library = typing -not_skip = __init__.py