diff --git a/.gitignore b/.gitignore index 1da5fee..4dfc343 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ /.project /.pydevproject /.coverage +build/ +dist/ +*.*~ +*~ \ No newline at end of file diff --git a/README.rst b/README.rst index 7b53ee4..47fc884 100644 --- a/README.rst +++ b/README.rst @@ -85,8 +85,8 @@ into a dictionary. telegram = parser.parse(telegram_str) print(telegram) # see 'Telegram object' docs below -Telegram object ---------------- +Telegram dictionary +------------------- A dictionary of which the key indicates the field type. These regex values correspond to one of dsmr_parser.obis_reference constants. @@ -138,6 +138,115 @@ Example to get some of the values: # See dsmr_reader.obis_references for all readable telegram values. # Note that the avilable values differ per DSMR version. +Telegram as an Object +--------------------- +An object version of the telegram is available as well. + + +.. code-block:: python + + # DSMR v4.2 p1 using dsmr_parser and telegram objects + + from dsmr_parser import telegram_specifications + from dsmr_parser.clients import SerialReader, SERIAL_SETTINGS_V5 + from dsmr_parser.objects import CosemObject, MBusObject, Telegram + from dsmr_parser.parsers import TelegramParser + import os + + serial_reader = SerialReader( + device='/dev/ttyUSB0', + serial_settings=SERIAL_SETTINGS_V5, + telegram_specification=telegram_specifications.V4 + ) + + # telegram = next(serial_reader.read_as_object()) + # print(telegram) + + for telegram in serial_reader.read_as_object(): + os.system('clear') + print(telegram) + +Example of output of print of the telegram object: + +.. code-block:: console + + P1_MESSAGE_HEADER: 42 [None] + P1_MESSAGE_TIMESTAMP: 2016-11-13 19:57:57+00:00 [None] + EQUIPMENT_IDENTIFIER: 3960221976967177082151037881335713 [None] + ELECTRICITY_USED_TARIFF_1: 1581.123 [kWh] + ELECTRICITY_USED_TARIFF_2: 1435.706 [kWh] + ELECTRICITY_DELIVERED_TARIFF_1: 0.000 [kWh] + ELECTRICITY_DELIVERED_TARIFF_2: 0.000 [kWh] + ELECTRICITY_ACTIVE_TARIFF: 0002 [None] + CURRENT_ELECTRICITY_USAGE: 2.027 [kW] + CURRENT_ELECTRICITY_DELIVERY: 0.000 [kW] + LONG_POWER_FAILURE_COUNT: 7 [None] + VOLTAGE_SAG_L1_COUNT: 0 [None] + VOLTAGE_SAG_L2_COUNT: 0 [None] + VOLTAGE_SAG_L3_COUNT: 0 [None] + VOLTAGE_SWELL_L1_COUNT: 0 [None] + VOLTAGE_SWELL_L2_COUNT: 0 [None] + VOLTAGE_SWELL_L3_COUNT: 0 [None] + TEXT_MESSAGE_CODE: None [None] + TEXT_MESSAGE: None [None] + DEVICE_TYPE: 3 [None] + INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE: 0.170 [kW] + INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE: 1.247 [kW] + INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE: 0.209 [kW] + INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE: 0.000 [kW] + INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE: 0.000 [kW] + INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE: 0.000 [kW] + EQUIPMENT_IDENTIFIER_GAS: 4819243993373755377509728609491464 [None] + HOURLY_GAS_METER_READING: 981.443 [m3] + +Accessing the telegrams information as attributes directly: + +.. code-block:: python + + telegram + Out[3]: + telegram.CURRENT_ELECTRICITY_USAGE + Out[4]: + telegram.CURRENT_ELECTRICITY_USAGE.value + Out[5]: Decimal('2.027') + telegram.CURRENT_ELECTRICITY_USAGE.unit + Out[6]: 'kW' + +The telegram object has an iterator, can be used to find all the information elements in the current telegram: + +.. code-block:: python + + [attr for attr, value in telegram] + Out[11]: + ['P1_MESSAGE_HEADER', + 'P1_MESSAGE_TIMESTAMP', + 'EQUIPMENT_IDENTIFIER', + 'ELECTRICITY_USED_TARIFF_1', + 'ELECTRICITY_USED_TARIFF_2', + 'ELECTRICITY_DELIVERED_TARIFF_1', + 'ELECTRICITY_DELIVERED_TARIFF_2', + 'ELECTRICITY_ACTIVE_TARIFF', + 'CURRENT_ELECTRICITY_USAGE', + 'CURRENT_ELECTRICITY_DELIVERY', + 'LONG_POWER_FAILURE_COUNT', + 'VOLTAGE_SAG_L1_COUNT', + 'VOLTAGE_SAG_L2_COUNT', + 'VOLTAGE_SAG_L3_COUNT', + 'VOLTAGE_SWELL_L1_COUNT', + 'VOLTAGE_SWELL_L2_COUNT', + 'VOLTAGE_SWELL_L3_COUNT', + 'TEXT_MESSAGE_CODE', + 'TEXT_MESSAGE', + 'DEVICE_TYPE', + 'INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE', + 'INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE', + 'INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE', + 'INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE', + 'INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE', + 'INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE', + 'EQUIPMENT_IDENTIFIER_GAS', + 'HOURLY_GAS_METER_READING'] + Installation ------------ diff --git a/dsmr_parser/clients/filereader.py b/dsmr_parser/clients/filereader.py new file mode 100644 index 0000000..e6eeb59 --- /dev/null +++ b/dsmr_parser/clients/filereader.py @@ -0,0 +1,122 @@ +import logging +import fileinput + +from dsmr_parser.clients.telegram_buffer import TelegramBuffer +from dsmr_parser.exceptions import ParseError, InvalidChecksumError +from dsmr_parser.objects import Telegram +from dsmr_parser.parsers import TelegramParser + +logger = logging.getLogger(__name__) + +class FileReader(object): + """ + Filereader to read and parse raw telegram strings from a file and instantiate Telegram objects + for each read telegram. + Usage: + from dsmr_parser import telegram_specifications + from dsmr_parser.clients.filereader import FileReader + + if __name__== "__main__": + + infile = '/data/smartmeter/readings.txt' + + file_reader = FileReader( + file = infile, + telegram_specification = telegram_specifications.V4 + ) + + for telegram in file_reader.read_as_object(): + print(telegram) + + The file can be created like: + from dsmr_parser import telegram_specifications + from dsmr_parser.clients import SerialReader, SERIAL_SETTINGS_V5 + + if __name__== "__main__": + + outfile = '/data/smartmeter/readings.txt' + + serial_reader = SerialReader( + device='/dev/ttyUSB0', + serial_settings=SERIAL_SETTINGS_V5, + telegram_specification=telegram_specifications.V4 + ) + + for telegram in serial_reader.read_as_object(): + f=open(outfile,"ab+") + f.write(telegram._telegram_data.encode()) + f.close() + """ + + def __init__(self, file, telegram_specification): + self._file = file + self.telegram_parser = TelegramParser(telegram_specification) + self.telegram_buffer = TelegramBuffer() + self.telegram_specification = telegram_specification + + def read_as_object(self): + """ + Read complete DSMR telegram's from a file and return a Telegram object. + :rtype: generator + """ + with open(self._file,"rb") as file_handle: + while True: + data = file_handle.readline() + str = data.decode() + self.telegram_buffer.append(str) + + for telegram in self.telegram_buffer.get_all(): + try: + yield Telegram(telegram, self.telegram_parser, self.telegram_specification) + except InvalidChecksumError as e: + logger.warning(str(e)) + except ParseError as e: + logger.error('Failed to parse telegram: %s', e) + +class FileInputReader(object): + """ + Filereader to read and parse raw telegram strings from stdin or files specified at the commandline + and instantiate Telegram objects for each read telegram. + Usage python script "syphon_smartmeter_readings_stdin.py": + from dsmr_parser import telegram_specifications + from dsmr_parser.clients.filereader import FileInputReader + + if __name__== "__main__": + + fileinput_reader = FileReader( + file = infile, + telegram_specification = telegram_specifications.V4 + ) + + for telegram in fileinput_reader.read_as_object(): + print(telegram) + + Command line: + tail -f /data/smartmeter/readings.txt | python3 syphon_smartmeter_readings_stdin.py + + """ + + def __init__(self, telegram_specification): + self.telegram_parser = TelegramParser(telegram_specification) + self.telegram_buffer = TelegramBuffer() + self.telegram_specification = telegram_specification + + def read_as_object(self): + """ + Read complete DSMR telegram's from stdin of filearguments specified on teh command line + and return a Telegram object. + :rtype: generator + """ + with fileinput.input(mode='rb') as file_handle: + while True: + data = file_handle.readline() + str = data.decode() + self.telegram_buffer.append(str) + + for telegram in self.telegram_buffer.get_all(): + try: + yield Telegram(telegram, self.telegram_parser, self.telegram_specification) + except InvalidChecksumError as e: + logger.warning(str(e)) + except ParseError as e: + logger.error('Failed to parse telegram: %s', e) diff --git a/dsmr_parser/clients/serial_.py b/dsmr_parser/clients/serial_.py index 9939194..94e3b6f 100644 --- a/dsmr_parser/clients/serial_.py +++ b/dsmr_parser/clients/serial_.py @@ -6,6 +6,7 @@ import serial_asyncio from dsmr_parser.clients.telegram_buffer import TelegramBuffer from dsmr_parser.exceptions import ParseError, InvalidChecksumError from dsmr_parser.parsers import TelegramParser +from dsmr_parser.objects import Telegram logger = logging.getLogger(__name__) @@ -20,6 +21,7 @@ class SerialReader(object): self.telegram_parser = TelegramParser(telegram_specification) self.telegram_buffer = TelegramBuffer() + self.telegram_specification = telegram_specification def read(self): """ @@ -41,6 +43,25 @@ class SerialReader(object): except ParseError as e: logger.error('Failed to parse telegram: %s', e) + def read_as_object(self): + """ + Read complete DSMR telegram's from the serial interface and return a Telegram object. + + :rtype: generator + """ + with serial.Serial(**self.serial_settings) as serial_handle: + while True: + data = serial_handle.readline() + self.telegram_buffer.append(data.decode('ascii')) + + for telegram in self.telegram_buffer.get_all(): + try: + yield Telegram(telegram, self.telegram_parser, self.telegram_specification) + except InvalidChecksumError as e: + logger.warning(str(e)) + except ParseError as e: + logger.error('Failed to parse telegram: %s', e) + class AsyncSerialReader(SerialReader): """Serial reader using asyncio pyserial.""" diff --git a/dsmr_parser/obis_name_mapping.py b/dsmr_parser/obis_name_mapping.py new file mode 100644 index 0000000..8f72654 --- /dev/null +++ b/dsmr_parser/obis_name_mapping.py @@ -0,0 +1,54 @@ +from dsmr_parser import obis_references as obis + +""" +dsmr_parser.obis_name_mapping +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module contains a mapping of obis references to names. +""" + +EN = { + obis.P1_MESSAGE_HEADER: 'P1_MESSAGE_HEADER', + obis.P1_MESSAGE_TIMESTAMP: 'P1_MESSAGE_TIMESTAMP', + obis.ELECTRICITY_IMPORTED_TOTAL : 'ELECTRICITY_IMPORTED_TOTAL', + obis.ELECTRICITY_USED_TARIFF_1 : 'ELECTRICITY_USED_TARIFF_1', + obis.ELECTRICITY_USED_TARIFF_2 : 'ELECTRICITY_USED_TARIFF_2', + obis.ELECTRICITY_DELIVERED_TARIFF_1 : 'ELECTRICITY_DELIVERED_TARIFF_1', + obis.ELECTRICITY_DELIVERED_TARIFF_2 : 'ELECTRICITY_DELIVERED_TARIFF_2', + obis.ELECTRICITY_ACTIVE_TARIFF : 'ELECTRICITY_ACTIVE_TARIFF', + obis.EQUIPMENT_IDENTIFIER : 'EQUIPMENT_IDENTIFIER', + obis.CURRENT_ELECTRICITY_USAGE : 'CURRENT_ELECTRICITY_USAGE', + obis.CURRENT_ELECTRICITY_DELIVERY : 'CURRENT_ELECTRICITY_DELIVERY', + obis.LONG_POWER_FAILURE_COUNT : 'LONG_POWER_FAILURE_COUNT', + obis.SHORT_POWER_FAILURE_COUNT : 'SHORT_POWER_FAILURE_COUNT', + obis.POWER_EVENT_FAILURE_LOG : 'POWER_EVENT_FAILURE_LOG', + obis.VOLTAGE_SAG_L1_COUNT : 'VOLTAGE_SAG_L1_COUNT', + obis.VOLTAGE_SAG_L2_COUNT : 'VOLTAGE_SAG_L2_COUNT', + obis.VOLTAGE_SAG_L3_COUNT : 'VOLTAGE_SAG_L3_COUNT', + obis.VOLTAGE_SWELL_L1_COUNT : 'VOLTAGE_SWELL_L1_COUNT', + obis.VOLTAGE_SWELL_L2_COUNT : 'VOLTAGE_SWELL_L2_COUNT', + obis.VOLTAGE_SWELL_L3_COUNT : 'VOLTAGE_SWELL_L3_COUNT', + obis.INSTANTANEOUS_VOLTAGE_L1 : 'INSTANTANEOUS_VOLTAGE_L1', + obis.INSTANTANEOUS_VOLTAGE_L2 : 'INSTANTANEOUS_VOLTAGE_L2', + obis.INSTANTANEOUS_VOLTAGE_L3 : 'INSTANTANEOUS_VOLTAGE_L3', + obis.INSTANTANEOUS_CURRENT_L1 : 'INSTANTANEOUS_CURRENT_L1', + obis.INSTANTANEOUS_CURRENT_L2 : 'INSTANTANEOUS_CURRENT_L2', + obis.INSTANTANEOUS_CURRENT_L3 : 'INSTANTANEOUS_CURRENT_L3', + obis.TEXT_MESSAGE_CODE : 'TEXT_MESSAGE_CODE', + obis.TEXT_MESSAGE : 'TEXT_MESSAGE', + obis.DEVICE_TYPE : 'DEVICE_TYPE', + obis.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE : 'INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE', + obis.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE : 'INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE', + obis.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE : 'INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE', + obis.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE : 'INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE', + obis.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE : 'INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE', + obis.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE : 'INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE', + obis.EQUIPMENT_IDENTIFIER_GAS : 'EQUIPMENT_IDENTIFIER_GAS', + obis.HOURLY_GAS_METER_READING : 'HOURLY_GAS_METER_READING', + obis.GAS_METER_READING : 'GAS_METER_READING', + obis.ACTUAL_TRESHOLD_ELECTRICITY : 'ACTUAL_TRESHOLD_ELECTRICITY', + obis.ACTUAL_SWITCH_POSITION : 'ACTUAL_SWITCH_POSITION', + obis.VALVE_POSITION_GAS : 'VALVE_POSITION_GAS' +} + +REVERSE_EN = dict([ (v,k) for k,v in EN.items()]) \ No newline at end of file diff --git a/dsmr_parser/objects.py b/dsmr_parser/objects.py index e6706c4..07d576d 100644 --- a/dsmr_parser/objects.py +++ b/dsmr_parser/objects.py @@ -1,3 +1,56 @@ +import dsmr_parser.obis_name_mapping + +class Telegram(object): + """ + Container for raw and parsed telegram data. + Initializing: + from dsmr_parser import telegram_specifications + from dsmr_parser.exceptions import InvalidChecksumError, ParseError + from dsmr_parser.objects import CosemObject, MBusObject, Telegram + from dsmr_parser.parsers import TelegramParser + from test.example_telegrams import TELEGRAM_V4_2 + parser = TelegramParser(telegram_specifications.V4) + telegram = Telegram(TELEGRAM_V4_2, parser, telegram_specifications.V4) + + Attributes can be accessed on a telegram object by addressing by their english name, for example: + telegram.ELECTRICITY_USED_TARIFF_1 + + All attributes in a telegram can be iterated over, for example: + [k for k,v in telegram] + yields: + ['P1_MESSAGE_HEADER', 'P1_MESSAGE_TIMESTAMP', 'EQUIPMENT_IDENTIFIER', ...] + """ + def __init__(self, telegram_data, telegram_parser, telegram_specification): + self._telegram_data = telegram_data + self._telegram_specification = telegram_specification + self._telegram_parser = telegram_parser + self._obis_name_mapping = dsmr_parser.obis_name_mapping.EN + self._reverse_obis_name_mapping = dsmr_parser.obis_name_mapping.REVERSE_EN + self._dictionary = self._telegram_parser.parse(telegram_data) + self._item_names = self._get_item_names() + + def __getattr__(self, name): + ''' will only get called for undefined attributes ''' + obis_reference = self._reverse_obis_name_mapping[name] + value = self._dictionary[obis_reference] + setattr(self, name, value) + return value + + def _get_item_names(self): + return [self._obis_name_mapping[k] for k, v in self._dictionary.items()] + + def __iter__(self): + for attr in self._item_names: + value = getattr(self, attr) + yield attr, value + + def __str__(self): + output = "" + for attr, value in self: + output += "{}: \t {} \t[{}]\n".format(attr,str(value.value),str(value.unit)) + return output + + class DSMRObject(object): """ Represents all data from a single telegram line. diff --git a/dsmr_parser/parsers.py b/dsmr_parser/parsers.py index 087d9e0..4b415f6 100644 --- a/dsmr_parser/parsers.py +++ b/dsmr_parser/parsers.py @@ -3,7 +3,7 @@ import re from PyCRC.CRC16 import CRC16 -from dsmr_parser.objects import MBusObject, CosemObject +from dsmr_parser.objects import MBusObject, CosemObject, Telegram from dsmr_parser.exceptions import ParseError, InvalidChecksumError logger = logging.getLogger(__name__) diff --git a/setup.py b/setup.py index fe434f6..e1f0139 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( author='Nigel Dokter', author_email='nigel@nldr.net', url='https://github.com/ndokter/dsmr_parser', - version='0.16', + version='0.17', packages=find_packages(), install_requires=[ 'pyserial>=3,<4', diff --git a/test/experiment_telegram.py b/test/experiment_telegram.py new file mode 100644 index 0000000..2649f51 --- /dev/null +++ b/test/experiment_telegram.py @@ -0,0 +1,14 @@ +from decimal import Decimal +import datetime +import unittest +import pytz +from dsmr_parser import obis_references as obis +from dsmr_parser import telegram_specifications +from dsmr_parser.exceptions import InvalidChecksumError, ParseError +from dsmr_parser.objects import CosemObject, MBusObject, Telegram +from dsmr_parser.parsers import TelegramParser +from example_telegrams import TELEGRAM_V4_2 +parser = TelegramParser(telegram_specifications.V4) +telegram = Telegram(TELEGRAM_V4_2, parser, telegram_specifications.V4) + +print(telegram) \ No newline at end of file diff --git a/test/test_telegram.py b/test/test_telegram.py new file mode 100644 index 0000000..d0e1042 --- /dev/null +++ b/test/test_telegram.py @@ -0,0 +1,30 @@ +from decimal import Decimal + +import datetime +import unittest + +import pytz + +from dsmr_parser import obis_references as obis +from dsmr_parser import telegram_specifications +from dsmr_parser.exceptions import InvalidChecksumError, ParseError +from dsmr_parser.objects import CosemObject, MBusObject, Telegram +from dsmr_parser.parsers import TelegramParser +from test.example_telegrams import TELEGRAM_V4_2 + +class TelegramTest(unittest.TestCase): + """ Test instantiation of Telegram object """ + + def test_instantiate(self): + parser = TelegramParser(telegram_specifications.V4) + #result = parser.parse(TELEGRAM_V4_2) + telegram = Telegram(TELEGRAM_V4_2, parser, telegram_specifications.V4) + + + + + # P1_MESSAGE_HEADER (1-3:0.2.8) + #assert isinstance(result[obis.P1_MESSAGE_HEADER], CosemObject) + #assert result[obis.P1_MESSAGE_HEADER].unit is None + #assert isinstance(result[obis.P1_MESSAGE_HEADER].value, str) + #assert result[obis.P1_MESSAGE_HEADER].value == '50' diff --git a/tox.ini b/tox.ini index 95660fe..23fe214 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ commands= pylama dsmr_parser test [pylama:dsmr_parser/clients/__init__.py] -ignore = W0611 +ignore = W0611,W0605 [pylama:pylint] max_line_length = 100