core/homeassistant/components/sensor/dsmr.py

265 lines
8.9 KiB
Python
Raw Normal View History

"""
2017-09-25 20:19:44 +00:00
Support for Dutch Smart Meter (also known as Smartmeter or P1 port).
2016-11-23 07:03:39 +00:00
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/sensor.dsmr/
"""
import asyncio
from datetime import timedelta
from functools import partial
import logging
2016-11-23 07:03:39 +00:00
import voluptuous as vol
2016-11-23 07:03:39 +00:00
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN)
from homeassistant.core import CoreState
import homeassistant.helpers.config_validation as cv
2016-11-23 07:03:39 +00:00
from homeassistant.helpers.entity import Entity
_LOGGER = logging.getLogger(__name__)
2016-11-23 07:03:39 +00:00
2017-09-25 20:19:44 +00:00
REQUIREMENTS = ['dsmr_parser==0.11']
2016-11-23 07:03:39 +00:00
CONF_DSMR_VERSION = 'dsmr_version'
CONF_RECONNECT_INTERVAL = 'reconnect_interval'
2016-11-23 07:03:39 +00:00
DEFAULT_DSMR_VERSION = '2.2'
DEFAULT_PORT = '/dev/ttyUSB0'
DOMAIN = 'dsmr'
ICON_GAS = 'mdi:fire'
ICON_POWER = 'mdi:flash'
# Smart meter sends telegram every 10 seconds
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
2017-09-25 20:19:44 +00:00
RECONNECT_INTERVAL = 5
2016-11-23 07:03:39 +00:00
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string,
vol.Optional(CONF_HOST): cv.string,
2016-11-23 07:03:39 +00:00
vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All(
cv.string, vol.In(['5', '4', '2.2'])),
vol.Optional(CONF_RECONNECT_INTERVAL, default=30): int,
2016-11-23 07:03:39 +00:00
})
@asyncio.coroutine
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
"""Set up the DSMR sensor."""
# Suppress logging
2016-11-23 07:03:39 +00:00
logging.getLogger('dsmr_parser').setLevel(logging.ERROR)
from dsmr_parser import obis_references as obis_ref
from dsmr_parser.clients.protocol import (
create_dsmr_reader, create_tcp_dsmr_reader)
import serial
2016-11-23 07:03:39 +00:00
dsmr_version = config[CONF_DSMR_VERSION]
# Define list of name,obis mappings to generate entities
2016-11-23 07:03:39 +00:00
obis_mapping = [
['Power Consumption', obis_ref.CURRENT_ELECTRICITY_USAGE],
['Power Production', obis_ref.CURRENT_ELECTRICITY_DELIVERY],
['Power Tariff', obis_ref.ELECTRICITY_ACTIVE_TARIFF],
['Power Consumption (low)', obis_ref.ELECTRICITY_USED_TARIFF_1],
['Power Consumption (normal)', obis_ref.ELECTRICITY_USED_TARIFF_2],
['Power Production (low)', obis_ref.ELECTRICITY_DELIVERED_TARIFF_1],
['Power Production (normal)', obis_ref.ELECTRICITY_DELIVERED_TARIFF_2],
2016-11-23 07:03:39 +00:00
]
# Generate device entities
devices = [DSMREntity(name, obis) for name, obis in obis_mapping]
# Protocol version specific obis
if dsmr_version in ('4', '5'):
gas_obis = obis_ref.HOURLY_GAS_METER_READING
2016-11-23 07:03:39 +00:00
else:
gas_obis = obis_ref.GAS_METER_READING
2016-11-23 07:03:39 +00:00
2017-09-25 20:19:44 +00:00
# Add gas meter reading and derivative for usage
devices += [
DSMREntity('Gas Consumption', gas_obis),
DerivativeDSMREntity('Hourly Gas Consumption', gas_obis),
]
2016-11-23 07:03:39 +00:00
async_add_devices(devices)
2016-11-23 07:03:39 +00:00
def update_entities_telegram(telegram):
"""Update entities with latest telegram and trigger state update."""
# Make all device entities aware of new telegram
2016-11-23 07:03:39 +00:00
for device in devices:
device.telegram = telegram
hass.async_add_job(device.async_update_ha_state())
2016-11-23 07:03:39 +00:00
2018-01-27 19:58:27 +00:00
# Creates an asyncio.Protocol factory for reading DSMR telegrams from
# serial and calls update_entities_telegram to update entities on arrival
if CONF_HOST in config:
reader_factory = partial(
create_tcp_dsmr_reader, config[CONF_HOST], config[CONF_PORT],
config[CONF_DSMR_VERSION], update_entities_telegram,
loop=hass.loop)
else:
reader_factory = partial(
create_dsmr_reader, config[CONF_PORT], config[CONF_DSMR_VERSION],
update_entities_telegram, loop=hass.loop)
2016-11-23 07:03:39 +00:00
@asyncio.coroutine
def connect_and_reconnect():
2017-09-25 20:19:44 +00:00
"""Connect to DSMR and keep reconnecting until Home Assistant stops."""
while hass.state != CoreState.stopping:
# Start DSMR asyncio.Protocol reader
try:
transport, protocol = yield from hass.loop.create_task(
reader_factory())
except (serial.serialutil.SerialException, ConnectionRefusedError,
TimeoutError):
2017-09-25 20:19:44 +00:00
# Log any error while establishing connection and drop to retry
# connection wait
_LOGGER.exception("Error connecting to DSMR")
transport = None
if transport:
2017-09-25 20:19:44 +00:00
# Register listener to close transport on HA shutdown
stop_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, transport.close)
2017-09-25 20:19:44 +00:00
# Wait for reader to close
yield from protocol.wait_closed()
if hass.state != CoreState.stopping:
2017-09-25 20:19:44 +00:00
# Unexpected disconnect
if transport:
# remove listener
stop_listener()
2017-09-25 20:19:44 +00:00
# Reflect disconnect state in devices state by setting an
# empty telegram resulting in `unknown` states
update_entities_telegram({})
# throttle reconnect attempts
yield from asyncio.sleep(config[CONF_RECONNECT_INTERVAL],
loop=hass.loop)
2017-09-25 20:19:44 +00:00
# Can't be hass.async_add_job because job runs forever
hass.loop.create_task(connect_and_reconnect())
2016-11-23 07:03:39 +00:00
class DSMREntity(Entity):
"""Entity reading values from DSMR telegram."""
def __init__(self, name, obis):
"""Initialize entity."""
2016-11-23 07:03:39 +00:00
self._name = name
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
2016-11-23 07:03:39 +00:00
if self._obis not in self.telegram:
return None
2017-09-25 20:19:44 +00:00
# Get the attribute value if the object has it
2016-11-23 07:03:39 +00:00
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)
if value is not None:
return value
return STATE_UNKNOWN
2016-11-23 07:03:39 +00:00
@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: Rate code 1 is used for low rate and rate code 2 is
# used for normal rate.
2016-11-23 07:03:39 +00:00
if value == '0002':
return 'normal'
elif value == '0001':
return 'low'
return STATE_UNKNOWN
class DerivativeDSMREntity(DSMREntity):
"""Calculated derivative for values where the DSMR doesn't offer one.
Gas readings are only reported per hour and don't offer a rate only
the current meter reading. This entity converts subsequents readings
into a hourly rate.
"""
_previous_reading = None
_previous_timestamp = None
_state = STATE_UNKNOWN
@property
def state(self):
"""Return the calculated current hourly rate."""
return self._state
@asyncio.coroutine
def async_update(self):
"""Recalculate hourly rate if timestamp has changed.
DSMR updates gas meter reading every hour. Along with the new
value a timestamp is provided for the reading. Test if the last
known timestamp differs from the current one then calculate a
new rate for the previous hour.
"""
# check if the timestamp for the object differs from the previous one
timestamp = self.get_dsmr_object_attr('datetime')
if timestamp and timestamp != self._previous_timestamp:
current_reading = self.get_dsmr_object_attr('value')
if self._previous_reading is None:
2017-09-25 20:19:44 +00:00
# Can't calculate rate without previous datapoint
# just store current point
pass
else:
2017-09-25 20:19:44 +00:00
# Recalculate the rate
diff = current_reading - self._previous_reading
self._state = diff
self._previous_reading = current_reading
self._previous_timestamp = timestamp
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, per hour, if any."""
unit = self.get_dsmr_object_attr('unit')
if unit:
return unit + '/h'