Initial commit
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user