-
-
Notifications
You must be signed in to change notification settings - Fork 28.8k
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
DSMR sensor #4309
Changes from 16 commits
3c1fd15
5b3f0ba
aed7627
4544685
ce70497
fc6e732
9c86d0a
e4594f1
27a4e01
1cf1341
aabbe6e
c5de690
8f30b51
42d7e23
5367334
873cbd4
749de01
30137d4
95e5a83
eb6c853
f5e9ccc
0762fca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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' | ||
] | ||
|
||
# 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()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's this for? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nicely spot, left that in there by accident. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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 |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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