Initial commit

This commit is contained in:
Nigel Dokter 2016-08-22 20:16:11 +02:00
commit fe278c2d3d
13 changed files with 455 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.idea
*.pyc

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2016 Nigel Dokter http://nldr.net
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

66
README.md Normal file
View File

@ -0,0 +1,66 @@
DSMR Parser
===========
A library for parsing Dutch Smart Meter Requirements (DSMR) telegram data. It
also includes a serial client to directly read and parse smart meter data.
Features
--------
DSMR Parser currently supports DSMR version 4 and is tested with Python 3.5
Examples
--------
Using the serial reader to connect to your smart meter and parse it's telegrams:
.. code-block:: python
from dsmr_parser import telegram_specifications
from dsmr_parser.obis_references import P1_MESSAGE_TIMESTAMP
from dsmr_parser.serial import SerialReader, SERIAL_SETTINGS_V4
serial_reader = SerialReader(
device='/dev/ttyUSB0',
serial_settings=SERIAL_SETTINGS_V4,
telegram_specification=telegram_specifications.V4
)
for telegram in serial_reader.read():
# The telegram message timestamp.
message_datetime = telegram[P1_MESSAGE_TIMESTAMP]
# Using the active tariff to determine the electricity being used and
# delivered for the right tariff.
tariff = telegram[ELECTRICITY_ACTIVE_TARIFF]
tariff = int(tariff.value)
electricity_used_total \
= telegram[ELECTRICITY_USED_TARIFF_ALL[tariff - 1]]
electricity_delivered_total = \
telegram[ELECTRICITY_DELIVERED_TARIFF_ALL[tariff - 1]]
gas_reading = telegram[HOURLY_GAS_METER_READING]
# See dsmr_reader.obis_references for all readable telegram values.
Installation
------------
To install DSMR Parser:
.. code-block:: bash
$ pip install dsmr-parser
TODO
----
- add unit tests
- verify telegram checksum
- improve ease of use

0
dsmr_parser/__init__.py Normal file
View File

View File

@ -0,0 +1,2 @@
class ParseError(Exception):
pass

View File

@ -0,0 +1,38 @@
P1_MESSAGE_HEADER = r'1-3:0\.2\.8'
P1_MESSAGE_TIMESTAMP = r'0-0:1\.0\.0'
ELECTRICITY_USED_TARIFF_1 = r'1-0:1\.8\.1'
ELECTRICITY_USED_TARIFF_2 = r'1-0:1\.8\.2'
ELECTRICITY_DELIVERED_TARIFF_1 = r'1-0:2\.8\.1'
ELECTRICITY_DELIVERED_TARIFF_2 = r'1-0:2\.8\.2'
ELECTRICITY_ACTIVE_TARIFF = r'0-0:96\.14\.0'
EQUIPMENT_IDENTIFIER = r'0-0:96\.1\.1'
CURRENT_ELECTRICITY_USAGE = r'1-0:1\.7\.0'
CURRENT_ELECTRICITY_DELIVERY = r'1-0:2\.7\.0'
LONG_POWER_FAILURE_COUNT = r'96\.7\.9'
POWER_EVENT_FAILURE_LOG = r'99\.97\.0'
VOLTAGE_SAG_L1_COUNT = r'1-0:32\.32\.0'
VOLTAGE_SAG_L2_COUNT = r'1-0:52\.32\.0'
VOLTAGE_SAG_L3_COUNT = r'1-0:72\.32\.0'
VOLTAGE_SWELL_L1_COUNT = r'1-0:32\.36\.0'
VOLTAGE_SWELL_L2_COUNT = r'1-0:52\.36\.0'
VOLTAGE_SWELL_L3_COUNT = r'1-0:72\.36\.0'
TEXT_MESSAGE_CODE = r'0-0:96\.13\.1'
TEXT_MESSAGE = r'0-0:96\.13\.0'
DEVICE_TYPE = r'0-\d:24\.1\.0'
INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE = r'1-0:21\.7\.0'
INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE = r'1-0:41\.7\.0'
INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE = r'1-0:61\.7\.0'
INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE = r'1-0:22\.7\.0'
INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE = r'1-0:42\.7\.0'
INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE = r'1-0:62\.7\.0'
EQUIPMENT_IDENTIFIER_GAS = r'0-\d:96\.1\.0'
HOURLY_GAS_METER_READING = r'0-1:24\.2\.1'
ELECTRICITY_USED_TARIFF_ALL = (
ELECTRICITY_USED_TARIFF_1,
ELECTRICITY_USED_TARIFF_2
)
ELECTRICITY_DELIVERED_TARIFF_ALL = (
ELECTRICITY_DELIVERED_TARIFF_1,
ELECTRICITY_DELIVERED_TARIFF_2
)

35
dsmr_parser/objects.py Normal file
View File

@ -0,0 +1,35 @@
class DSMRObject(object):
def __init__(self, values):
self.values = values
class MBusObject(DSMRObject):
@property
def datetime(self):
return self.values[0]['value']
@property
def value(self):
return self.values[1]['value']
@property
def unit(self):
return self.values[1]['unit']
class CosemObject(DSMRObject):
@property
def value(self):
return self.values[0]['value']
@property
def unit(self):
return self.values[0]['unit']
class ProfileGeneric(DSMRObject):
pass
# TODO implement

159
dsmr_parser/parsers.py Normal file
View File

@ -0,0 +1,159 @@
import logging
import re
from .objects import MBusObject, CosemObject
from .exceptions import ParseError
logger = logging.getLogger(__name__)
class TelegramParser(object):
def __init__(self, telegram_specification):
"""
:param telegram_specification: determines how the telegram is parsed
:type telegram_specification: dict
"""
self.telegram_specification = telegram_specification
def _find_line_parser(self, line_value):
for obis_reference, parser in self.telegram_specification.items():
if re.search(obis_reference, line_value):
return obis_reference, parser
return None, None
def parse(self, line_values):
telegram = {}
for line_value in line_values:
obis_reference, dsmr_object = self.parse_line(line_value)
telegram[obis_reference] = dsmr_object
return telegram
def parse_line(self, line_value):
logger.debug('Parsing line\'%s\'', line_value)
obis_reference, parser = self._find_line_parser(line_value)
if not parser:
logger.warning("No line class found for: '%s'", line_value)
return None, None
return obis_reference, parser.parse(line_value)
class DSMRObjectParser(object):
def __init__(self, *value_formats):
self.value_formats = value_formats
def _parse(self, line):
# Match value groups, but exclude the parentheses
pattern = re.compile(r'((?<=\()[0-9a-zA-Z\.\*]{0,}(?=\)))+')
values = re.findall(pattern, line)
# Convert empty value groups to None for clarity.
values = [None if value == '' else value for value in values]
if not values or len(values) != len(self.value_formats):
raise ParseError("Invalid '%s' line for '%s'", line, self)
return [self.value_formats[i].parse(value)
for i, value in enumerate(values)]
class MBusParser(DSMRObjectParser):
"""
Gas meter value parser.
These are lines with a timestamp and gas meter value.
Line format:
'ID (TST) (Mv1*U1)'
1 2 3 4
1) OBIS Reduced ID-code
2) Time Stamp (TST) of capture time of measurement value
3) Measurement value 1 (most recent entry of buffer attribute without unit)
4) Unit of measurement values (Unit of capture objects attribute)
"""
def parse(self, line):
return MBusObject(self._parse(line))
class CosemParser(DSMRObjectParser):
"""
Cosem object parser.
These are data objects with a single value that optionally have a unit of
measurement.
Line format:
ID (Mv*U)
1 23 45
1) OBIS Reduced ID-code
2) Separator (, ASCII 28h
3) COSEM object attribute value
4) Unit of measurement values (Unit of capture objects attribute) only if applicable
5) Separator ), ASCII 29h
"""
def parse(self, line):
return CosemObject(self._parse(line))
class ProfileGenericParser(DSMRObjectParser):
"""
Power failure log parser.
These are data objects with multiple repeating groups of values.
Line format:
ID (z) (ID1) (TST) (Bv1*U1) (TST) (Bvz*Uz)
1 2 3 4 5 6 7 8 9
1) OBIS Reduced ID-code
2) Number of values z (max 10).
3) Identifications of buffer values (OBIS Reduced ID codes of capture objects attribute)
4) Time Stamp (TST) of power failure end time
5) Buffer value 1 (most recent entry of buffer attribute without unit)
6) Unit of buffer values (Unit of capture objects attribute)
7) Time Stamp (TST) of power failure end time
8) Buffer value 2 (oldest entry of buffer attribute without unit)
9) Unit of buffer values (Unit of capture objects attribute)
"""
def parse(self, line):
raise NotImplementedError()
class ValueParser(object):
def __init__(self, coerce_type):
self.coerce_type = coerce_type
def parse(self, value):
unit_of_measurement = None
if value and '*' in value:
value, unit_of_measurement = value.split('*')
# A value group is not required to have a value, and then coercing does
# not apply.
value = self.coerce_type(value) if value is not None else value
return {
'value': value,
'unit': unit_of_measurement
}

55
dsmr_parser/serial.py Normal file
View File

@ -0,0 +1,55 @@
import serial
from dsmr_parser.parsers import TelegramParser
SERIAL_SETTINGS_V4 = {
'baudrate': 115200,
'bytesize': serial.SEVENBITS,
'parity': serial.PARITY_EVEN,
'stopbits': serial.STOPBITS_ONE,
'xonxoff': 0,
'rtscts': 0,
'timeout': 20
}
def is_start_of_telegram(line):
return line.startswith('/')
def is_end_of_telegram(line):
return line.startswith('!')
class SerialReader(object):
def __init__(self, device, serial_settings, telegram_specification):
self.serial_settings = serial_settings
self.serial_settings['port'] = device
self.telegram_parser = TelegramParser(telegram_specification)
def read(self):
"""
Read complete DSMR telegram's from the serial interface and parse it
into CosemObject's and MbusObject's
:rtype dict
"""
with serial.Serial(**self.serial_settings) as serial_handle:
telegram = []
while True:
line = serial_handle.readline()
line = line.decode('ascii')
# Telegrams need to be complete because the values belong to a
# particular reading and can also be related to eachother.
if not telegram and not is_start_of_telegram(line):
continue
telegram.append(line)
if is_end_of_telegram(line):
yield self.telegram_parser.parse(telegram)
telegram = []

View File

@ -0,0 +1,48 @@
from decimal import Decimal
from .obis_references import *
from .parsers import CosemParser, ValueParser, MBusParser
from .value_types import timestamp
"""
dsmr_parser.telegram_specifications
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This module contains DSMR telegram specifications. Each specifications describes
how the telegram lines are parsed.
"""
V4 = {
P1_MESSAGE_HEADER: CosemParser(ValueParser(str)),
P1_MESSAGE_TIMESTAMP: CosemParser(ValueParser(timestamp)),
ELECTRICITY_USED_TARIFF_1: CosemParser(ValueParser(Decimal)),
ELECTRICITY_USED_TARIFF_2: CosemParser(ValueParser(Decimal)),
ELECTRICITY_DELIVERED_TARIFF_1: CosemParser(ValueParser(Decimal)),
ELECTRICITY_DELIVERED_TARIFF_2: CosemParser(ValueParser(Decimal)),
ELECTRICITY_ACTIVE_TARIFF: CosemParser(ValueParser(str)),
EQUIPMENT_IDENTIFIER: CosemParser(ValueParser(str)),
CURRENT_ELECTRICITY_USAGE: CosemParser(ValueParser(Decimal)),
CURRENT_ELECTRICITY_DELIVERY: CosemParser(ValueParser(Decimal)),
LONG_POWER_FAILURE_COUNT: CosemParser(ValueParser(int)),
# POWER_EVENT_FAILURE_LOG: ProfileGenericParser(), TODO
VOLTAGE_SAG_L1_COUNT: CosemParser(ValueParser(int)),
VOLTAGE_SAG_L2_COUNT: CosemParser(ValueParser(int)),
VOLTAGE_SAG_L3_COUNT: CosemParser(ValueParser(int)),
VOLTAGE_SWELL_L1_COUNT: CosemParser(ValueParser(int)),
VOLTAGE_SWELL_L2_COUNT: CosemParser(ValueParser(int)),
VOLTAGE_SWELL_L3_COUNT: CosemParser(ValueParser(int)),
TEXT_MESSAGE_CODE: CosemParser(ValueParser(int)),
TEXT_MESSAGE: CosemParser(ValueParser(str)),
DEVICE_TYPE: CosemParser(ValueParser(int)),
INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE: CosemParser(ValueParser(Decimal)),
INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE: CosemParser(ValueParser(Decimal)),
INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE: CosemParser(ValueParser(Decimal)),
INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE: CosemParser(ValueParser(Decimal)),
INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE: CosemParser(ValueParser(Decimal)),
INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE: CosemParser(ValueParser(Decimal)),
EQUIPMENT_IDENTIFIER_GAS: CosemParser(ValueParser(str)),
HOURLY_GAS_METER_READING: MBusParser(ValueParser(timestamp),
ValueParser(Decimal))
}

View File

@ -0,0 +1,14 @@
import datetime
import pytz
def timestamp(value):
naive_datetime = datetime.datetime.strptime(value[:-1], '%y%m%d%H%M%S')
is_dst = value[12] == 'S' # assume format 160322150000W
local_tz = pytz.timezone('Europe/Amsterdam')
localized_datetime = local_tz.localize(naive_datetime, is_dst=is_dst)
return localized_datetime.astimezone(pytz.utc)

2
setup.cfg Normal file
View File

@ -0,0 +1,2 @@
[metadata]
description-file = README.md

13
setup.py Normal file
View File

@ -0,0 +1,13 @@
from setuptools import setup, find_packages
setup(
name='dsmr-parser',
description='Library to parse Dutch Smart Meter Requirements (DSMR)',
author='Nigel Dokter',
version='0.1',
packages=find_packages(),
install_requires=[
'pyserial==3.0.1',
'pytz==2016.3'
]
)