From fe278c2d3dcf97d25dc6dcc888ccf7f610ece515 Mon Sep 17 00:00:00 2001 From: Nigel Dokter Date: Mon, 22 Aug 2016 20:16:11 +0200 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE | 21 ++++ README.md | 66 ++++++++++ dsmr_parser/__init__.py | 0 dsmr_parser/exceptions.py | 2 + dsmr_parser/obis_references.py | 38 ++++++ dsmr_parser/objects.py | 35 ++++++ dsmr_parser/parsers.py | 159 +++++++++++++++++++++++++ dsmr_parser/serial.py | 55 +++++++++ dsmr_parser/telegram_specifications.py | 48 ++++++++ dsmr_parser/value_types.py | 14 +++ setup.cfg | 2 + setup.py | 13 ++ 13 files changed, 455 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dsmr_parser/__init__.py create mode 100644 dsmr_parser/exceptions.py create mode 100644 dsmr_parser/obis_references.py create mode 100644 dsmr_parser/objects.py create mode 100644 dsmr_parser/parsers.py create mode 100644 dsmr_parser/serial.py create mode 100644 dsmr_parser/telegram_specifications.py create mode 100644 dsmr_parser/value_types.py create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c10666e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f272df9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6d7493 --- /dev/null +++ b/README.md @@ -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 diff --git a/dsmr_parser/__init__.py b/dsmr_parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dsmr_parser/exceptions.py b/dsmr_parser/exceptions.py new file mode 100644 index 0000000..831cca9 --- /dev/null +++ b/dsmr_parser/exceptions.py @@ -0,0 +1,2 @@ +class ParseError(Exception): + pass diff --git a/dsmr_parser/obis_references.py b/dsmr_parser/obis_references.py new file mode 100644 index 0000000..7fbb24e --- /dev/null +++ b/dsmr_parser/obis_references.py @@ -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 +) diff --git a/dsmr_parser/objects.py b/dsmr_parser/objects.py new file mode 100644 index 0000000..5024dba --- /dev/null +++ b/dsmr_parser/objects.py @@ -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 diff --git a/dsmr_parser/parsers.py b/dsmr_parser/parsers.py new file mode 100644 index 0000000..ae08e27 --- /dev/null +++ b/dsmr_parser/parsers.py @@ -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 + } diff --git a/dsmr_parser/serial.py b/dsmr_parser/serial.py new file mode 100644 index 0000000..ee80a67 --- /dev/null +++ b/dsmr_parser/serial.py @@ -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 = [] + diff --git a/dsmr_parser/telegram_specifications.py b/dsmr_parser/telegram_specifications.py new file mode 100644 index 0000000..bcab475 --- /dev/null +++ b/dsmr_parser/telegram_specifications.py @@ -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)) +} + diff --git a/dsmr_parser/value_types.py b/dsmr_parser/value_types.py new file mode 100644 index 0000000..4154d50 --- /dev/null +++ b/dsmr_parser/value_types.py @@ -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) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b88034e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.md diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f95903a --- /dev/null +++ b/setup.py @@ -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' + ] +)