Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DSMR sensor #4309

Merged
merged 22 commits into from
Nov 23, 2016
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
176 changes: 176 additions & 0 deletions homeassistant/components/sensor/dsmr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""
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 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.helpers.entity import Entity

DOMAIN = 'dsmr'

REQUIREMENTS = [
'https://github.com/aequitas/dsmr_parser/archive/async_protocol.zip'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if this will be merged upstream? Or are you planning on maintaining an async fork?

This also needs a version at the end ...zip#dsmr-parser==0.4 for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't know for sure yet, but the maintainer has been very keen to merge my changes so far so I don't foresee issue there. If not I will create my fork. I added a todo to this PR to ensure this gets taken care of.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged upstream, just waiting for new version to be deployed: ndokter/dsmr_parser#7

]

# 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):
"""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())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely spot, left that in there by accident.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May I suggest adding: https://github.com/aequitas/pytest-eradicate to the project?


# 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):
""""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
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ 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
Expand Down
64 changes: 64 additions & 0 deletions tests/components/sensor/test_dsmr.py
Original file line number Diff line number Diff line change
@@ -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