From fe278c2d3dcf97d25dc6dcc888ccf7f610ece515 Mon Sep 17 00:00:00 2001
From: Nigel Dokter <nigeldokter@gmail.com>
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'
+    ]
+)