Initial commit
This commit is contained in:
commit
fe278c2d3d
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.idea
|
||||
*.pyc
|
21
LICENSE
Normal file
21
LICENSE
Normal 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
66
README.md
Normal 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
0
dsmr_parser/__init__.py
Normal file
2
dsmr_parser/exceptions.py
Normal file
2
dsmr_parser/exceptions.py
Normal file
@ -0,0 +1,2 @@
|
||||
class ParseError(Exception):
|
||||
pass
|
38
dsmr_parser/obis_references.py
Normal file
38
dsmr_parser/obis_references.py
Normal 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
35
dsmr_parser/objects.py
Normal 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
159
dsmr_parser/parsers.py
Normal 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
55
dsmr_parser/serial.py
Normal 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 = []
|
||||
|
48
dsmr_parser/telegram_specifications.py
Normal file
48
dsmr_parser/telegram_specifications.py
Normal 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))
|
||||
}
|
||||
|
14
dsmr_parser/value_types.py
Normal file
14
dsmr_parser/value_types.py
Normal 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)
|
13
setup.py
Normal file
13
setup.py
Normal 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'
|
||||
]
|
||||
)
|
Loading…
Reference in New Issue
Block a user