From ab0ae9829796449b0a23211b554a063b90d5ae96 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Sun, 1 Dec 2024 22:07:02 +0100 Subject: [PATCH 01/34] smartmeter [wip]: initial commit, --- smartmeter/__init__.py | 364 ++++++++++++ smartmeter/algorithms.py | 235 ++++++++ smartmeter/conversion.py | 160 +++++ smartmeter/dlms-sample.txt | 278 +++++++++ smartmeter/dlms.py | 669 +++++++++++++++++++++ smartmeter/dlms_test.py | 281 +++++++++ smartmeter/get_manufacturer_ids.py | 133 +++++ smartmeter/plugin.yaml | 244 ++++++++ smartmeter/sml.py | 917 +++++++++++++++++++++++++++++ 9 files changed, 3281 insertions(+) create mode 100644 smartmeter/__init__.py create mode 100755 smartmeter/algorithms.py create mode 100755 smartmeter/conversion.py create mode 100644 smartmeter/dlms-sample.txt create mode 100755 smartmeter/dlms.py create mode 100644 smartmeter/dlms_test.py create mode 100755 smartmeter/get_manufacturer_ids.py create mode 100644 smartmeter/plugin.yaml create mode 100644 smartmeter/sml.py diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py new file mode 100644 index 000000000..83f7395ac --- /dev/null +++ b/smartmeter/__init__.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2012-2014 Oliver Hinckel github@ollisnet.de +# Copyright 2018-2024 Bernd Meiners Bernd.Meiners@mail.de +# Copyright 2022- Michael Wenzel wenzel_michael@web.de +# Copyright 2024- Sebastian Helms morg @ knx-user-forum.de +######################################################################### +# +# This file is part of SmartHomeNG. https://github.com/smarthomeNG// +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +######################################################################### + +__license__ = 'GPL' +__version__ = '2.0' +__revision__ = '0.1' +__docformat__ = 'reStructuredText' + +import threading +import sys + +# find out if we can import serial - if not, the plugin might not start anyway +# serial is not needed in the plugin itself, but in the modules SML and DLMS, +# which will import the serial module by themselves +try: + import serial # noqa + REQUIRED_PACKAGE_IMPORTED = True +except Exception: + REQUIRED_PACKAGE_IMPORTED = False + +from lib.model.smartplugin import SmartPlugin +from lib.item.item import Item +from lib.shtime import Shtime +from collections.abc import Callable + +from . import dlms +from . import sml +from .conversion import Conversion +try: + from .webif import WebInterface +except ImportError: + pass + +shtime = Shtime.get_instance() + +# item attributes handled by this plugin +OBIS_CODE = 'obis_code' # single code, '1-1:1.8.0' or '1.8.0' +OBIS_INDEX = 'obis_index' # optional: index of obis value, default 0 +OBIS_PROPERTY = 'obis_property' # optional: property to read ('value', 'unit', ...) default 'value'' +OBIS_VTYPE = 'obis_vtype' # optional: type of value (str, num, int, float, ZST12, ZST10, D6, Z6, Z4, '') default '' +OBIS_READOUT = 'obis_readout' # complete readout (dlms only) + +ITEM_ATTRS = (OBIS_CODE, OBIS_INDEX, OBIS_PROPERTY, OBIS_VTYPE, OBIS_READOUT) + +# obis properties with default (empty) values +PROPS = { + 'value': [], + 'unit': '' +} + + +class Smartmeter(SmartPlugin, Conversion): + """ + Main class of the Plugin. Does all plugin specific stuff and provides + the update functions for the items + """ + + PLUGIN_VERSION = '0.0.1' + + def __init__(self, sh): + """ + Initializes the plugin. The parameters described for this method are pulled from the entry in plugin.conf. + """ + + # Call init code of parent class (SmartPlugin) + super().__init__() + + # load parameters from config + self._protocol = None + self.load_parameters() + + # quit if errors on parameter read + if not self._init_complete: + return + + self.connected = False + self.alive = False + + self._items = {} # all items by obis code by obis prop + self._readout_items = [] # all readout items + self.obis_codes = [] + + self._lock = threading.Lock() + + # self.init_webinterface(WebInterface) + + def load_parameters(self): + + # + # connection configuration + # + self._config = {} + + # first try connections; abort loading plugin if no connection is configured + self._config['serial_port'] = self.get_parameter_value('serialport') + if self._config['serial_port'] and not REQUIRED_PACKAGE_IMPORTED: + self.logger.error('serial port requested but package "pyserial" could not be imported.') + self._init_complete = False + return + + # serial has priority, as DLMS only uses serial + if self._config['serial_port']: + self._config['connection'] = 'serial' + else: + host = self.get_parameter_value('host') + port = self.get_parameter_value('port') + if host and port: + self._config['host'] = host + self._config['port'] = port + self._config['connection'] = 'network' + else: + self.logger.error('neither serial nor network connection configured.') + self._init_complete = False + return + + # there is a possibility of using a named device + # normally this will be empty since only one meter will be attached + # to one serial interface but the standard allows for it and we honor that. + self._config['timeout'] = self.get_parameter_value('timeout') + self._config['baudrate'] = self.get_parameter_value('baudrate') + + # get mode (SML/DLMS) if set by user + # if not set, try to get at runtime + self._protocol = self.get_parameter_value('protocol') + + # DLMS only + self._config['dlms'] = {} + self._config['dlms']['device'] = self.get_parameter_value('device_address') + self._config['dlms']['querycode'] = self.get_parameter_value('querycode') + self._config['dlms']['baudrate_fix'] = self.get_parameter_value('baudrate_fix') + self._config['dlms']['baudrate_min'] = self.get_parameter_value('baudrate_min') + self._config['dlms']['use_checksum'] = self.get_parameter_value('use_checksum') + self._config['dlms']['onlylisten'] = self.get_parameter_value('only_listen') + # self._config['dlms']['reset_baudrate'] = self.get_parameter_value('reset_baudrate') + # self._config['dlms']['no_waiting'] = self.get_parameter_value('no_waiting') + + # SML only + self._config['sml'] = {} + self._config['sml']['device'] = self.get_parameter_value('device_type') + self._config['sml']['buffersize'] = self.get_parameter_value('buffersize') # 1024 + self._config['sml']['date_offset'] = self.get_parameter_value('date_offset') # 0 + self._config['sml']['poly'] = self.get_parameter_value('poly') # 0x1021 + self._config['sml']['reflect_in'] = self.get_parameter_value('reflect_in') # True + self._config['sml']['xor_in'] = self.get_parameter_value('xor_in') # 0xffff + self._config['sml']['reflect_out'] = self.get_parameter_value('reflect_out') # True + self._config['sml']['xor_out'] = self.get_parameter_value('xor_out') # 0xffff + self._config['sml']['swap_crc_bytes'] = self.get_parameter_value('swap_crc_bytes') # False + + # + # general plugin parameters + # + self.cycle = self.get_parameter_value('cycle') + if self.cycle == 0: + self.cycle = None + + self.crontab = self.get_parameter_value('crontab') # the more complex way to specify the device query frequency + if self.crontab == '': + self.crontab = None + + if not (self.cycle or self.crontab): + self.logger.warning(f'{self.get_fullname()}: no update cycle or crontab set. The smartmeter will not be queried automatically') + + def _get_module(self): + """ return module reference for SML/DMLS module """ + name = __name__ + '.' + str(self._protocol).lower() + ref = sys.modules.get(name) + if not ref: + self.logger.warning(f"couldn't get reference for module {name}...") + return ref + + def run(self): + """ + Run method for the plugin + """ + self.logger.debug('run method called') + + # TODO: reload parameters - why? + self.load_parameters() + + if not self._protocol: + # TODO: call DLMS/SML discovery routines to find protocol +# +# TODO: module.discover needs to probe for communication and return True if succeeded +# + if sml.discover(self._config): + self._protocol = 'SML' + elif dlms.discover(self._config): + self._protocol = 'DLMS' + + self.alive = True + if self._protocol: + self.logger.info(f'set/detected protocol {self._protocol}') + else: + self.logger.error('unable to auto-detect device protocol (SML/DLMS). Try manual disconvery via standalone mode or Web Interface.') + # skip cycle / crontab scheduler if no protocol set (only manual control from web interface) + return + + # Setup scheduler for device poll loop, if protocol set + if (self.cycle or self.crontab) and self._protocol: + if self.crontab: + next = None # adhere to the crontab + else: + # no crontab given so we might just query immediately + next = shtime.now() + self.scheduler_add(self.get_fullname(), self.poll_device, prio=5, cycle=self.cycle, cron=self.crontab, next=next) + self.logger.debug('run method finished') + + def stop(self): + """ + Stop method for the plugin + """ + self.logger.debug('stop method called') + self.alive = False + try: + self.scheduler_remove(self.get_fullname()) + except Exception: + pass + + def parse_item(self, item: Item) -> Callable | None: + """ + Default plugin parse_item method. Is called when the plugin is initialized. + + :param item: The item to process. + :return: returns update_item function if changes are to be watched + """ + if self.has_iattr(item.conf, OBIS_CODE): + obis = self.get_iattr_value(item.conf, OBIS_CODE) + prop = self.get_iattr_value(item.conf, OBIS_PROPERTY, default='value') + if prop not in PROPS: + self.logger.warning(f'item {item}: invalid property {prop} requested for obis {obis}, setting default "value"') + prop = 'value' + index = self.get_iattr_value(item.conf, OBIS_INDEX, default=0) + vtype = self.get_iattr_value(item.conf, OBIS_VTYPE, default='') + # TODO: crosscheck vtype and item type + + self.add_item(item, {'property': prop, 'index': index, 'vtype': vtype}, obis) + + if obis not in self._items: + self._items[obis] = {} + if prop not in self._items[obis]: + self._items[obis][prop] = [] + self._items[obis][prop].append(item) + self.obis_codes.append(obis) + self.logger.debug(f'Attach {item.property.path} with obis={obis} and prop={prop}') + + if self.has_iattr(item.conf, OBIS_READOUT): + self.add_item(item) + self._readout_items.append(item) + self.logger.debug(f'Attach {item.property.path} for readout') + + def _is_obis_code_wanted(self, code: str) -> bool: + """ + this stub function detects whether code is in the list of user defined OBIS codes to scan for + """ + return code in self.obis_codes + + def poll_device(self): + """ + This function aquires a lock, calls the 'query device' method of the + respective module and upon successful data readout it calls the update function + If it is not possible it passes on, issuing a warning about increasing the query interval + """ + self.logger.debug(f'poll_device called, module is {self._get_module()}') + if not self._get_module(): + return + + if self._lock.acquire(blocking=False): + self.logger.debug('lock acquired') + try: +# +# module.query needs to return a dict: +# { +# 'readout': '', (only for dlms?) +# 'obis1': [{'value': val0, optional 'unit': unit1}, {'value': val1, optional 'unit': unit1'}] +# 'obis2': [{...}] +# } +# + result = self._get_module().query(self._config) + if not result: + self.logger.warning('no results from smartmeter query received') + else: + self.logger.debug(f'got result: {result}') + self._update_values(result) + except Exception as e: + self.logger.error(f'error: {e}', exc_info=True) + finally: + self._lock.release() + self.logger.debug('lock released') + else: + self.logger.warning('device query is alrady running. Check connection and/or use longer query interval time.') + + def _update_values(self, result: dict): + """ + this function takes the OBIS Code as text and accepts a list of dictionaries with Values + :param Code: OBIS Code + :param Values: list of dictionaries with Value / Unit entries + """ + if 'readout' in result: + for item in self._readout_items: + item(result['readout'], self.get_fullname()) + self.logger.debug(f'set item {item} to readout {result["readout"]}') + del result['readout'] + + for obis, vlist in result.items(): + if obis in self._items: + entry = self._items[obis] + for prop, items in entry.items(): + if prop not in PROPS: + self.logger.warning(f'invalid property {prop} requested for obis {obis}, ignoring') + continue + for item in items: + conf = self.get_item_config(item) + index = conf.get('index', 0) + itemValue = vlist[index].get(prop, PROPS[prop]) + if prop == 'value': + try: + val = vlist[index][prop] + converter = conf['vtype'] + itemValue = self._convert_value(val, converter) + # self.logger.debug(f'conversion yielded {itemValue} from {val} for converter "{converter}"') + except IndexError: + self.logger.warning(f'value for index {index} not found in {vlist["value"]}, skipping...') + continue + except KeyError as e: + self.logger.warning(f'key error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') + except NameError as e: + self.logger.warning(f'name error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') + # TODO: add more props? -> sml! + else: + if itemValue is None: + itemValue = '' + + item(itemValue, self.get_fullname()) + self.logger.debug(f'set item {item} for obis code {obis}:{prop} to value "{itemValue}"') + + @property + def item_list(self): + return self.get_item_list() + + @property + def log_level(self): + return self.logger.getEffectiveLevel() diff --git a/smartmeter/algorithms.py b/smartmeter/algorithms.py new file mode 100755 index 000000000..3ebcebd53 --- /dev/null +++ b/smartmeter/algorithms.py @@ -0,0 +1,235 @@ +# pycrc -- parameterisable CRC calculation utility and C source code generator +# +# Copyright (c) 2006-2017 Thomas Pircher +# +# 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. + + +""" +CRC algorithms implemented in Python. +If you want to study the Python implementation of the CRC routines, then this +is a good place to start from. + +The algorithms Bit by Bit, Bit by Bit Fast and Table-Driven are implemented. + +This module can also be used as a library from within Python. + +Examples +======== + +This is an example use of the different algorithms: + + from pycrc.algorithms import Crc + + crc = Crc(width = 16, poly = 0x8005, + reflect_in = True, xor_in = 0x0000, + reflect_out = True, xor_out = 0x0000) + print("{0:#x}".format(crc.bit_by_bit("123456789"))) + print("{0:#x}".format(crc.bit_by_bit_fast("123456789"))) + print("{0:#x}".format(crc.table_driven("123456789"))) +""" + +class Crc(object): + """ + A base class for CRC routines. + """ + # pylint: disable=too-many-instance-attributes + + def __init__(self, width, poly, reflect_in, xor_in, reflect_out, xor_out, table_idx_width=None, slice_by=1): + """The Crc constructor. + + The parameters are as follows: + width + poly + reflect_in + xor_in + reflect_out + xor_out + """ + # pylint: disable=too-many-arguments + + self.width = width + self.poly = poly + self.reflect_in = reflect_in + self.xor_in = xor_in + self.reflect_out = reflect_out + self.xor_out = xor_out + self.tbl_idx_width = table_idx_width + self.slice_by = slice_by + + self.msb_mask = 0x1 << (self.width - 1) + self.mask = ((self.msb_mask - 1) << 1) | 1 + if self.tbl_idx_width != None: + self.tbl_width = 1 << self.tbl_idx_width + else: + self.tbl_idx_width = 8 + self.tbl_width = 1 << self.tbl_idx_width + + self.direct_init = self.xor_in + self.nondirect_init = self.__get_nondirect_init(self.xor_in) + if self.width < 8: + self.crc_shift = 8 - self.width + else: + self.crc_shift = 0 + + + def __get_nondirect_init(self, init): + """ + return the non-direct init if the direct algorithm has been selected. + """ + crc = init + for dummy_i in range(self.width): + bit = crc & 0x01 + if bit: + crc ^= self.poly + crc >>= 1 + if bit: + crc |= self.msb_mask + return crc & self.mask + + + def reflect(self, data, width): + """ + reflect a data word, i.e. reverts the bit order. + """ + # pylint: disable=no-self-use + + res = data & 0x01 + for dummy_i in range(width - 1): + data >>= 1 + res = (res << 1) | (data & 0x01) + return res + + + def bit_by_bit(self, in_data): + """ + Classic simple and slow CRC implementation. This function iterates bit + by bit over the augmented input message and returns the calculated CRC + value at the end. + """ + # If the input data is a string, convert to bytes. + if isinstance(in_data, str): + in_data = bytearray(in_data, 'utf-8') + + reg = self.nondirect_init + for octet in in_data: + if self.reflect_in: + octet = self.reflect(octet, 8) + for i in range(8): + topbit = reg & self.msb_mask + reg = ((reg << 1) & self.mask) | ((octet >> (7 - i)) & 0x01) + if topbit: + reg ^= self.poly + + for i in range(self.width): + topbit = reg & self.msb_mask + reg = ((reg << 1) & self.mask) + if topbit: + reg ^= self.poly + + if self.reflect_out: + reg = self.reflect(reg, self.width) + return (reg ^ self.xor_out) & self.mask + + + def bit_by_bit_fast(self, in_data): + """ + This is a slightly modified version of the bit-by-bit algorithm: it + does not need to loop over the augmented bits, i.e. the Width 0-bits + wich are appended to the input message in the bit-by-bit algorithm. + """ + # If the input data is a string, convert to bytes. + if isinstance(in_data, str): + in_data = bytearray(in_data, 'utf-8') + + reg = self.direct_init + for octet in in_data: + if self.reflect_in: + octet = self.reflect(octet, 8) + for i in range(8): + topbit = reg & self.msb_mask + if octet & (0x80 >> i): + topbit ^= self.msb_mask + reg <<= 1 + if topbit: + reg ^= self.poly + reg &= self.mask + if self.reflect_out: + reg = self.reflect(reg, self.width) + return reg ^ self.xor_out + + + def gen_table(self): + """ + This function generates the CRC table used for the table_driven CRC + algorithm. The Python version cannot handle tables of an index width + other than 8. See the generated C code for tables with different sizes + instead. + """ + table_length = 1 << self.tbl_idx_width + tbl = [[0 for i in range(table_length)] for j in range(self.slice_by)] + for i in range(table_length): + reg = i + if self.reflect_in: + reg = self.reflect(reg, self.tbl_idx_width) + reg = reg << (self.width - self.tbl_idx_width + self.crc_shift) + for dummy_j in range(self.tbl_idx_width): + if reg & (self.msb_mask << self.crc_shift) != 0: + reg = (reg << 1) ^ (self.poly << self.crc_shift) + else: + reg = (reg << 1) + if self.reflect_in: + reg = self.reflect(reg >> self.crc_shift, self.width) << self.crc_shift + tbl[0][i] = (reg >> self.crc_shift) & self.mask + + for j in range(1, self.slice_by): + for i in range(table_length): + tbl[j][i] = (tbl[j - 1][i] >> 8) ^ tbl[0][tbl[j - 1][i] & 0xff] + return tbl + + + def table_driven(self, in_data): + """ + The Standard table_driven CRC algorithm. + """ + # pylint: disable = line-too-long + + # If the input data is a string, convert to bytes. + if isinstance(in_data, str): + in_data = bytearray(in_data, 'utf-8') + + tbl = self.gen_table() + + if not self.reflect_in: + reg = self.direct_init << self.crc_shift + for octet in in_data: + tblidx = ((reg >> (self.width - self.tbl_idx_width + self.crc_shift)) ^ octet) & 0xff + reg = ((reg << (self.tbl_idx_width - self.crc_shift)) ^ (tbl[0][tblidx] << self.crc_shift)) & (self.mask << self.crc_shift) + reg = reg >> self.crc_shift + else: + reg = self.reflect(self.direct_init, self.width) + for octet in in_data: + tblidx = (reg ^ octet) & 0xff + reg = ((reg >> self.tbl_idx_width) ^ tbl[0][tblidx]) & self.mask + reg = self.reflect(reg, self.width) & self.mask + + if self.reflect_out: + reg = self.reflect(reg, self.width) + return reg ^ self.xor_out + diff --git a/smartmeter/conversion.py b/smartmeter/conversion.py new file mode 100755 index 000000000..7a35c5a9d --- /dev/null +++ b/smartmeter/conversion.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2016 - 2018 Bernd Meiners Bernd.Meiners@mail.de +######################################################################### +# +# This file is part of SmartHomeNG.py. +# Visit: https://github.com/smarthomeNG/ +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# SmartHomeNG.py is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG.py is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG.py. If not, see . +######################################################################### + + +__license__ = "GPL" +__version__ = "2.0" +__revision__ = "0.1" +__docformat__ = 'reStructuredText' + +import datetime + +CONVERTERS = { + 'int': 'int', + 'float': 'float', + 'ZST10': 'datetime', + 'ZST12': 'datetime', + 'D6': 'date', + 'Z6': 'time', + 'Z4': 'time', + 'num': 'num' +} + + +class Conversion: + def _from_ZST10(self, text: str) -> datetime.datetime: + """ + this function converts a string of form "YYMMDDhhmm" into a datetime object + :param text: string to convert + :return: a datetime object upon success or None if error found by malformed string + """ + if len(text) != 10: + raise ValueError("too few characters for date/time code from OBIS") + + year = int(text[0:2]) + 2000 + month = int(text[2:4]) + day = int(text[4:6]) + hour = int(text[6:8]) + minute = int(text[8:10]) + return datetime.datetime(year, month, day, hour, minute, 0) + + def _from_ZST12(self, text: str) -> datetime.datetime: + """ + this function converts a string of form "YYMMDDhhmmss" into a datetime object + :param text: string to convert + :return: a datetime object upon success or None if error found by malformed string + """ + if len(text) != 12: + raise ValueError("too few characters for date/time code from OBIS") + + year = int(text[0:2]) + 2000 + month = int(text[2:4]) + day = int(text[4:6]) + hour = int(text[6:8]) + minute = int(text[8:10]) + second = int(text[10:12]) + return datetime.datetime(year, month, day, hour, minute, second) + + def _from_D6(self, text: str) -> datetime.date: + """ + this function converts a string of form "YYMMDD" into a datetime.date object + :param text: string to convert + :return: a datetime.date object upon success or None if error found by malformed string + """ + if len(text) != 6: + raise ValueError("too few characters for date code from OBIS") + + year = int(text[0:2]) + 2000 + month = int(text[2:4]) + day = int(text[4:6]) + return datetime.date(year, month, day) + + def _from_Z4(self, text: str) -> datetime.time: + """ + this function converts a string of form "hhmm" into a datetime.time object + :param text: string to convert + :return: a datetime.time object upon success or None if error found by malformed string + """ + if len(text) != 4: + raise ValueError("too few characters for time code from OBIS") + + hour = int(text[0:2]) + minute = int(text[2:4]) + return datetime.time(hour, minute) + + def _from_Z6(self, text: str) -> datetime.time: + """ + this function converts a string of form "hhmmss" into a datetime.time object + :param text: string to convert + :return: a datetime.time object upon success or None if error found by malformed string + """ + if len(text) != 6: + raise ValueError("too few characters for time code from OBIS") + + hour = int(text[0:2]) + minute = int(text[2:4]) + second = int(text[4:6]) + return datetime.time(hour, minute, second) + + def _convert_value(self, val, converter: str = ''): + """ + This function converts the OBIS value to a user chosen valalue + :param val: the value to convert given as string + :param converter: type of value, should contain one of CONVERTERS + :return: after successful conversion the value in converted form + """ + if converter not in CONVERTERS: + return val + + try: + if converter in ('num', 'float'): + + if converter == 'num' and val.isdigit(): + return int(val) + + # try/except to catch floats like '1.0' and '1,0' + try: + return float(val) + except ValueError: + if ',' in val: + val = val.replace(',', '.') + return float(val) + else: + raise ValueError + + if not val.isdigit(): + raise ValueError("only digits allowed for date/time code from OBIS") + + if converter == 'int': + return int(val) + + # try to find self._from_ -> run it and return result + if hasattr(self, f'_from_{converter}'): + return getattr(self, f'_from_{converter}')(val) + + # no suitable converter found + raise ValueError + + except ValueError as e: + raise ValueError(f'could not convert from "{val}" to a {CONVERTERS[converter]} ({e})') diff --git a/smartmeter/dlms-sample.txt b/smartmeter/dlms-sample.txt new file mode 100644 index 000000000..a44f99b23 --- /dev/null +++ b/smartmeter/dlms-sample.txt @@ -0,0 +1,278 @@ +1-1:F.F(00000000) +1-1:0.0.0(97734234) +1-1:0.0.1(97734234) +1-1:0.9.1(145051) +1-1:0.9.2(241129) +1-1:0.1.2(0000) +1-1:0.1.3(241101) +1-1:0.1.0(30) +1-1:1.2.1(0501.70*kW) +1-1:1.2.2(0501.70*kW) +1-1:2.2.1(0123.46*kW) +1-1:2.2.2(0123.46*kW) +1-1:1.6.1(07.98*kW)(2411061415) +1-1:1.6.1*30(07.60)(2410101115) +1-1:1.6.1*29(06.10)(2409160830) +1-1:1.6.1*28(05.35)(2408081545) +1-1:1.6.1*27(04.11)(2407181515) +1-1:1.6.1*26(05.26)(2406041400) +1-1:1.6.1*25(06.80)(2405311000) +1-1:1.6.1*24(04.50)(2404110945) +1-1:1.6.1*23(11.15)(2403051545) +1-1:1.6.1*22(09.15)(2402211445) +1-1:1.6.1*21(08.97)(2401191030) +1-1:1.6.1*20(24.08)(2312121045) +1-1:1.6.1*19(18.56)(2311060845) +1-1:1.6.1*18(23.05)(2310241530) +1-1:1.6.1*17(20.60)(2309111330) +1-1:1.6.1*16(21.48)(2308251330) +1-1:1.6.2(07.98*kW)(2411061415) +1-1:1.6.2*30(07.60)(2410101115) +1-1:1.6.2*29(06.10)(2409160830) +1-1:1.6.2*28(05.35)(2408081545) +1-1:1.6.2*27(04.11)(2407181515) +1-1:1.6.2*26(05.26)(2406041400) +1-1:1.6.2*25(06.80)(2405311000) +1-1:1.6.2*24(04.50)(2404110945) +1-1:1.6.2*23(11.15)(2403051545) +1-1:1.6.2*22(09.15)(2402211445) +1-1:1.6.2*21(08.97)(2401191030) +1-1:1.6.2*20(24.08)(2312121045) +1-1:1.6.2*19(18.56)(2311060845) +1-1:1.6.2*18(23.05)(2310241530) +1-1:1.6.2*17(20.60)(2309111330) +1-1:1.6.2*16(21.48)(2308251330) +1-1:2.6.1(01.84*kW)(2411021345) +1-1:2.6.1*30(03.32)(2410051445) +1-1:2.6.1*29(04.35)(2409011430) +1-1:2.6.1*28(05.62)(2408311415) +1-1:2.6.1*27(06.31)(2407141445) +1-1:2.6.1*26(06.43)(2406151330) +1-1:2.6.1*25(06.15)(2405251315) +1-1:2.6.1*24(05.84)(2404211345) +1-1:2.6.1*23(04.99)(2403251400) +1-1:2.6.1*22(02.58)(2402171330) +1-1:2.6.1*21(01.35)(2401271345) +1-1:2.6.1*20(00.54)(2312251200) +1-1:2.6.1*19(00.84)(2311121315) +1-1:2.6.1*18(03.24)(2310141415) +1-1:2.6.1*17(04.43)(2309031430) +1-1:2.6.1*16(05.76)(2308031445) +1-1:2.6.2(01.84*kW)(2411021345) +1-1:2.6.2*30(03.32)(2410051445) +1-1:2.6.2*29(04.35)(2409011430) +1-1:2.6.2*28(05.62)(2408311415) +1-1:2.6.2*27(06.31)(2407141445) +1-1:2.6.2*26(06.43)(2406151330) +1-1:2.6.2*25(06.15)(2405251315) +1-1:2.6.2*24(05.84)(2404211345) +1-1:2.6.2*23(04.99)(2403251400) +1-1:2.6.2*22(02.58)(2402171330) +1-1:2.6.2*21(01.35)(2401271345) +1-1:2.6.2*20(00.54)(2312251200) +1-1:2.6.2*19(00.84)(2311121315) +1-1:2.6.2*18(03.24)(2310141415) +1-1:2.6.2*17(04.43)(2309031430) +1-1:2.6.2*16(05.76)(2308031445) +1-1:1.8.0(00043802*kWh) +1-1:1.8.0*30(00042781) +1-1:1.8.0*29(00041912) +1-1:1.8.0*28(00041227) +1-1:1.8.0*27(00040639) +1-1:1.8.0*26(00040118) +1-1:1.8.0*25(00039674) +1-1:1.8.0*24(00039139) +1-1:1.8.0*23(00038600) +1-1:1.8.0*22(00037417) +1-1:1.8.0*21(00035961) +1-1:1.8.0*20(00034776) +1-1:1.8.0*19(00032557) +1-1:1.8.0*18(00030476) +1-1:1.8.0*17(00028420) +1-1:1.8.0*16(00026978) +1-1:2.8.0(00005983*kWh) +1-1:2.8.0*30(00005979) +1-1:2.8.0*29(00005931) +1-1:2.8.0*28(00005717) +1-1:2.8.0*27(00005284) +1-1:2.8.0*26(00004705) +1-1:2.8.0*25(00004135) +1-1:2.8.0*24(00003546) +1-1:2.8.0*23(00003198) +1-1:2.8.0*22(00003075) +1-1:2.8.0*21(00003063) +1-1:2.8.0*20(00003060) +1-1:2.8.0*19(00003059) +1-1:2.8.0*18(00003056) +1-1:2.8.0*17(00003027) +1-1:2.8.0*16(00002869) +1-1:3.8.0(00021203*kvarh) +1-1:3.8.0*30(00021129) +1-1:3.8.0*29(00021035) +1-1:3.8.0*28(00020920) +1-1:3.8.0*27(00020788) +1-1:3.8.0*26(00020697) +1-1:3.8.0*25(00020622) +1-1:3.8.0*24(00020429) +1-1:3.8.0*23(00020403) +1-1:3.8.0*22(00020116) +1-1:3.8.0*21(00019929) +1-1:3.8.0*20(00019739) +1-1:3.8.0*19(00018838) +1-1:3.8.0*18(00017921) +1-1:3.8.0*17(00016923) +1-1:3.8.0*16(00016094) +1-1:4.8.0(00006222*kvarh) +1-1:4.8.0*30(00005926) +1-1:4.8.0*29(00005638) +1-1:4.8.0*28(00005404) +1-1:4.8.0*27(00005179) +1-1:4.8.0*26(00004943) +1-1:4.8.0*25(00004722) +1-1:4.8.0*24(00004526) +1-1:4.8.0*23(00004306) +1-1:4.8.0*22(00004054) +1-1:4.8.0*21(00003799) +1-1:4.8.0*20(00003550) +1-1:4.8.0*19(00003344) +1-1:4.8.0*18(00003156) +1-1:4.8.0*17(00002957) +1-1:4.8.0*16(00002771) +1-1:1.8.1(00035256*kWh) +1-1:1.8.1*30(00034502) +1-1:1.8.1*29(00033921) +1-1:1.8.1*28(00033497) +1-1:1.8.1*27(00033174) +1-1:1.8.1*26(00032943) +1-1:1.8.1*25(00032746) +1-1:1.8.1*24(00032461) +1-1:1.8.1*23(00032177) +1-1:1.8.1*22(00031377) +1-1:1.8.1*21(00030337) +1-1:1.8.1*20(00029431) +1-1:1.8.1*19(00027499) +1-1:1.8.1*18(00025699) +1-1:1.8.1*17(00023923) +1-1:1.8.1*16(00022750) +1-1:1.8.2(00008545*kWh) +1-1:1.8.2*30(00008279) +1-1:1.8.2*29(00007990) +1-1:1.8.2*28(00007730) +1-1:1.8.2*27(00007465) +1-1:1.8.2*26(00007174) +1-1:1.8.2*25(00006927) +1-1:1.8.2*24(00006678) +1-1:1.8.2*23(00006422) +1-1:1.8.2*22(00006039) +1-1:1.8.2*21(00005623) +1-1:1.8.2*20(00005344) +1-1:1.8.2*19(00005057) +1-1:1.8.2*18(00004777) +1-1:1.8.2*17(00004496) +1-1:1.8.2*16(00004227) +1-1:2.8.1(00005983*kWh) +1-1:2.8.1*30(00005979) +1-1:2.8.1*29(00005931) +1-1:2.8.1*28(00005717) +1-1:2.8.1*27(00005284) +1-1:2.8.1*26(00004705) +1-1:2.8.1*25(00004135) +1-1:2.8.1*24(00003546) +1-1:2.8.1*23(00003198) +1-1:2.8.1*22(00003075) +1-1:2.8.1*21(00003063) +1-1:2.8.1*20(00003060) +1-1:2.8.1*19(00003059) +1-1:2.8.1*18(00003056) +1-1:2.8.1*17(00003027) +1-1:2.8.1*16(00002869) +1-1:2.8.2(00000000*kWh) +1-1:2.8.2*30(00000000) +1-1:2.8.2*29(00000000) +1-1:2.8.2*28(00000000) +1-1:2.8.2*27(00000000) +1-1:2.8.2*26(00000000) +1-1:2.8.2*25(00000000) +1-1:2.8.2*24(00000000) +1-1:2.8.2*23(00000000) +1-1:2.8.2*22(00000000) +1-1:2.8.2*21(00000000) +1-1:2.8.2*20(00000000) +1-1:2.8.2*19(00000000) +1-1:2.8.2*18(00000000) +1-1:2.8.2*17(00000000) +1-1:2.8.2*16(00000000) +1-1:3.8.1(00021081*kvarh) +1-1:3.8.1*30(00021007) +1-1:3.8.1*29(00020913) +1-1:3.8.1*28(00020800) +1-1:3.8.1*27(00020679) +1-1:3.8.1*26(00020597) +1-1:3.8.1*25(00020523) +1-1:3.8.1*24(00020330) +1-1:3.8.1*23(00020304) +1-1:3.8.1*22(00020023) +1-1:3.8.1*21(00019837) +1-1:3.8.1*20(00019647) +1-1:3.8.1*19(00018746) +1-1:3.8.1*18(00017829) +1-1:3.8.1*17(00016835) +1-1:3.8.1*16(00016012) +1-1:3.8.2(00000122*kvarh) +1-1:3.8.2*30(00000122) +1-1:3.8.2*29(00000122) +1-1:3.8.2*28(00000119) +1-1:3.8.2*27(00000109) +1-1:3.8.2*26(00000099) +1-1:3.8.2*25(00000099) +1-1:3.8.2*24(00000099) +1-1:3.8.2*23(00000099) +1-1:3.8.2*22(00000092) +1-1:3.8.2*21(00000092) +1-1:3.8.2*20(00000092) +1-1:3.8.2*19(00000092) +1-1:3.8.2*18(00000092) +1-1:3.8.2*17(00000088) +1-1:3.8.2*16(00000081) +1-1:4.8.1(00003666*kvarh) +1-1:4.8.1*30(00003482) +1-1:4.8.1*29(00003302) +1-1:4.8.1*28(00003159) +1-1:4.8.1*27(00003025) +1-1:4.8.1*26(00002882) +1-1:4.8.1*25(00002746) +1-1:4.8.1*24(00002628) +1-1:4.8.1*23(00002497) +1-1:4.8.1*22(00002342) +1-1:4.8.1*21(00002182) +1-1:4.8.1*20(00002019) +1-1:4.8.1*19(00001898) +1-1:4.8.1*18(00001790) +1-1:4.8.1*17(00001678) +1-1:4.8.1*16(00001572) +1-1:4.8.2(00002555*kvarh) +1-1:4.8.2*30(00002444) +1-1:4.8.2*29(00002335) +1-1:4.8.2*28(00002245) +1-1:4.8.2*27(00002153) +1-1:4.8.2*26(00002060) +1-1:4.8.2*25(00001975) +1-1:4.8.2*24(00001897) +1-1:4.8.2*23(00001809) +1-1:4.8.2*22(00001712) +1-1:4.8.2*21(00001616) +1-1:4.8.2*20(00001530) +1-1:4.8.2*19(00001446) +1-1:4.8.2*18(00001365) +1-1:4.8.2*17(00001279) +1-1:4.8.2*16(00001198) +1-1:C.3.1(___-----) +1-1:C.3.2(__------) +1-1:C.3.3(__------) +1-1:C.3.4(--__5_--) +1-1:C.4.0(60C00183) +1-1:C.5.0(0020E0F0) +1-1:C.7.0(00000041) +1-1:0.2.0(B31) +1-1:0.2.1(005) +! \ No newline at end of file diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py new file mode 100755 index 000000000..4aa0a06f1 --- /dev/null +++ b/smartmeter/dlms.py @@ -0,0 +1,669 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2013 - 2015 KNX-User-Forum e.V. http://knx-user-forum.de/ +# Copyright 2016 - 2022 Bernd Meiners Bernd.Meiners@mail.de +# Copyright 2024 - Sebastian Helms morg @ knx-user-forum.de +######################################################################### +# +# DLMS plugin for SmartHomeNG +# +# This file is part of SmartHomeNG.py. +# Visit: https://github.com/smarthomeNG/ +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# https://smarthomeng.de +# +# SmartHomeNG.py is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG.py is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG.py. If not, see . +######################################################################### + + +__license__ = "GPL" +__version__ = "2.0" +__revision__ = "0.1" +__docformat__ = 'reStructuredText' + +import logging +import datetime +import time +import serial +import re + +from ruamel.yaml import YAML + +""" +This module implements the query of a smartmeter using the DLMS protocol. +The smartmeter needs to have an infrared interface and an IR-Adapter is needed for USB. + +The Character Format for protocol mode A - D is defined as 1 start bit, 7 data bits, 1 parity bit, 1 stop bit and even parity +In protocol mode E it is defined as 1 start bit, 8 data bits, 1 stop bit is allowed, see Annex E of IEC62056-21 +For this plugin the protocol mode E is neither implemented nor supported. + +Abbreviations +------------- +COSEM + COmpanion Specification for Energy Metering + +OBIS + OBject Identification System (see iec62056-61{ed1.0}en_obis_protocol.pdf) + +""" + +if __name__ == '__main__': + logger = logging.getLogger(__name__) + logger.debug(f"init standalone {__name__}") +else: + logger = logging.getLogger(__name__) + logger.debug(f"init plugin component {__name__}") + +# +# protocol constants +# + +SOH = 0x01 # start of header +STX = 0x02 # start of text +ETX = 0x03 # end of text +ACK = 0x06 # acknowledge +CR = 0x0D # carriage return +LF = 0x0A # linefeed +BCC = 0x00 # Block check Character will contain the checksum immediately following the data packet + + +# +# internal testing +# +TESTING = False +TESTING = True + +if TESTING: + from .dlms_test import RESULT + logger.error('DLMS testing mode enabled, no serial communication, no real results!') +else: + RESULT = '' + +manufacturer_ids = {} +exportfile = 'manufacturer.yaml' +try: + with open(exportfile, 'r') as infile: + y = YAML(typ='safe') + manufacturer_ids = y.load(infile) +except Exception: + pass + + +def discover(config: dict) -> bool: + """ try to autodiscover SML protocol """ + # TODO: write this... + return True + + +def format_time(timedelta: float) -> str: + """ + returns a pretty formatted string according to the size of the timedelta + :param timediff: time delta given in seconds + :return: returns a string + """ + if timedelta > 1000: + return f"{timedelta:.2f} s" + elif timedelta > 1: + return f"{timedelta:.2f} s" + elif timedelta > 1 / 10 ** 3: + return f"{timedelta * 10 ** 3 :.2f} ms" + elif timedelta > 1 / 10 ** 6: + return f"{timedelta * 10 ** 6:.2f} µs" + else: + return f"{timedelta * 10 ** 9:.2f} ns" + + +def read_data_block_from_serial(the_serial: serial.Serial, end_byte: bytes = b'\n', start_byte: bytes = b'', max_read_time: int = -1) -> bytes: + """ + This function reads some bytes from serial interface + it returns an array of bytes if a timeout occurs or a given end byte is encountered + and otherwise None if an error occurred + + If global var TESTING is True, only pre-stored data will be returned to test further processing! + + :param the_serial: interface to read from + :param end_byte: the indicator for end of data, this will be included in response + :param start_byte: the indicator for start of data, this will be included in response + :param max_read_time: + :returns the read data or None + """ + if TESTING: + return RESULT.encode() + + logger.debug("start to read data from serial device") + response = bytes() + starttime = time.time() + start_found = False + try: + while True: + ch = the_serial.read() + # logger.debug(f"Read {ch}") + runtime = time.time() + if len(ch) == 0: + break + if start_byte != b'': + if ch == start_byte: + response = bytes() + start_found = True + response += ch + if ch == end_byte: + if start_byte is not None and not start_found: + response = bytes() + continue + else: + break + if (response[-1] == end_byte): + break + if max_read_time is not None: + if runtime - starttime > max_read_time: + break + except Exception as e: + logger.debug(f"error occurred while reading data block from serial: {e} ") + return b'' + logger.debug(f"finished reading data from serial device after {len(response)} bytes") + return response + + +def split_header(readout: str, break_at_eod: bool = True) -> list: + """if there is an empty line at second position within readout then seperate this""" + has_header = False + obis = [] + endofdata_count = 0 + for linecount, line in enumerate(readout.splitlines()): + if linecount == 0 and line.startswith("/"): + has_header = True + continue + + # an empty line separates the header from the codes, it must be suppressed here + if len(line) == 0 and linecount == 1 and has_header: + continue + + # if there is an empty line other than directly after the header + # it is very likely that there is a faulty obis readout. + # It might be that checksum is disabled an thus no error could be catched + if len(line) == 0: + logger.error("incorrect format: empty line was encountered unexpectedly, aborting!") + break + + # '!' as single OBIS code line means 'end of data' + if line.startswith("!"): + logger.debug("end of data reached") + if endofdata_count: + logger.debug(f"found {endofdata_count} end of data marker '!' in readout") + if break_at_eod: # omit the rest of data here + break + endofdata_count += 1 + else: + obis.append(line) + return obis + + +def query(config) -> dict: + """ + This function will + 1. open a serial communication line to the smartmeter + 2. sends a request for info + 3. parses the devices first (and maybe second) answer for capabilities of the device + 4. adjusts the speed of the communication accordingly + 5. reads out the block of OBIS information + 6. closes the serial communication + 7. strip header lines from returned data + 8. extract obis data and format return dict + + config contains a dict with entries for + 'serial_port', 'device' and a sub-dict 'dlms' with entries for + 'querycode', 'baudrate', 'baudrate_fix', 'timeout', 'onlylisten', 'use_checksum' + + return: a dict with the response data formatted as follows: + { + 'readout': , + '': [{'value': , (optional) 'unit': ''}, {'value': ', 'unit': ''}, ...], + '': [...], + ... + } + + The obis lines contain at least one value (index 0), possibly with a unit, and possibly more values in analogous format + """ + + # TODO: modularize; find components to reuse with SML? + + # + # initialize module + # + + # for the performance of the serial read we need to save the current time + starttime = time.time() + runtime = starttime + result = None + + try: + serial_port = config['serial_port'] + timeout = config['timeout'] + + device = config['dlms']['device'] + initial_baudrate = config['dlms']['baudrate_min'] + # baudrate_fix = config['dlms']['baudrate_fix'] + query_code = config['dlms']['querycode'] + use_checksum = config['dlms']['use_checksum'] + only_listen = config['dlms'].get('onlylisten', False) # just for the case that smartmeter transmits data without a query first + except (KeyError, AttributeError) as e: + logger.warning(f'configuration {config} is missing elements: {e}') + return {} + + logger.debug(f"config='{config}'") + start_char = b'/' + + request_message = b"/" + query_code.encode('ascii') + device.encode('ascii') + b"!\r\n" + + # + # open the serial communication + # + + # about timeout: time tr between sending a request and an answer needs to be + # 200ms < tr < 1500ms for protocol mode A or B + # inter character time must be smaller than 1500 ms + # The time between the reception of a message and the transmission of an answer is: + # (20 ms) 200 ms = tr = 1 500 ms (see item 12) of 6.3.14). + # If a response has not been received, the waiting time of the transmitting equipment after + # transmission of the identification message, before it continues with the transmission, is: + # 1 500 ms < tt = 2 200 ms + # The time between two characters in a character sequence is: + # ta < 1 500 ms + wait_before_acknowledge = 0.4 # wait for 400 ms before sending the request to change baudrate + wait_after_acknowledge = 0.4 # wait for 400 ms after sending acknowledge + + dlms_serial = None + try: + dlms_serial = serial.Serial(serial_port, + initial_baudrate, + bytesize=serial.SEVENBITS, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + timeout=timeout) + if not serial_port == dlms_serial.name: + logger.debug(f"Asked for {serial_port} as serial port, but really using now {dlms_serial.name}") + + except FileNotFoundError: + logger.error(f"Serial port '{serial_port}' does not exist, please check your port") + return {} + except serial.SerialException: + if dlms_serial is None: + logger.error(f"Serial port '{serial_port}' could not be opened") + else: + logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") + except OSError: + logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") + return {} + except Exception as e: + logger.error(f"unforeseen error occurred: '{e}'") + return {} + + if dlms_serial is None: + # this should not happen... + logger.error("unforeseen error occurred, serial object was not initialized.") + return {} + + if not dlms_serial.is_open: + logger.error(f"serial port '{serial_port}' could not be opened with given parameters, maybe wrong baudrate?") + return {} + + logger.debug(f"time to open serial port {serial_port}: {format_time(time.time() - runtime)}") + runtime = time.time() + + acknowledge = b'' # preset empty answer + + if not only_listen: + # TODO: check/implement later + response = b'' + + # start a dialog with smartmeter + try: + # TODO: is this needed? when? + # logger.debug(f"Reset input buffer from serial port '{serial_port}'") + # dlms_serial.reset_input_buffer() # replaced dlms_serial.flushInput() + logger.debug(f"writing request message {request_message} to serial port '{serial_port}'") + dlms_serial.write(request_message) + # TODO: same as above + # logger.debug(f"Flushing buffer from serial port '{serial_port}'") + # dlms_serial.flush() # replaced dlms_serial.drainOutput() + except Exception as e: + logger.warning(f"error on serial write: {e}") + return {} + + logger.debug(f"time to send first request to smartmeter: {format_time(time.time() - runtime)}") + + # now get first response + response = read_data_block_from_serial(dlms_serial) + if not response: + logger.debug("no response received upon first request") + return {} + + logger.debug(f"time to receive an answer: {format_time(time.time() - runtime)}") + runtime = time.time() + + # We need to examine the read response here for an echo of the _Request_Message + # some meters answer with an echo of the request Message + if response == request_message: + logger.debug("request message was echoed, need to read the identification message") + # now read the capabilities and type/brand line from Smartmeter + # e.g. b'/LGZ5\\2ZMD3104407.B32\r\n' + response = read_data_block_from_serial(dlms_serial) + else: + logger.debug("request message was not equal to response, treating as identification message") + + logger.debug(f"time to get first identification message from smartmeter: {format_time(time.time() - runtime)}") + runtime = time.time() + + identification_message = response + logger.debug(f"identification message is {identification_message}") + + # need at least 7 bytes: + # 1 byte "/" + # 3 bytes short Identification + # 1 byte speed indication + # 2 bytes CR LF + if len(identification_message) < 7: + logger.warning(f"malformed identification message: '{identification_message}', abort query") + return {} + + if (identification_message[0] != start_char): + logger.warning(f"identification message '{identification_message}' does not start with '/', abort query") + return {} + + manid = str(identification_message[1:4], 'utf-8') + manname = manufacturer_ids.get(manid, 'unknown') + logger.debug(f"manufacturer for {manid} is {manname} ({len(manufacturer_ids)} manufacturers known)") + + # Different smartmeters allow for different protocol modes. + # The protocol mode decides whether the communication is fixed to a certain baudrate or might be speed up. + # Some meters do initiate a protocol by themselves with a fixed speed of 2400 baud e.g. Mode D + # However some meters specify a speed of 9600 Baud although they use protocol mode D (readonly) + # + # Protocol_Mode = 'A' + # + # The communication of the plugin always stays at the same speed, + # Protocol indicator can be anything except for A-I, 0-9, /, ? + # + baudrates = { + # mode A + '': 300, + # mode B + 'A': (600, 'B'), + 'B': (1200, 'B'), + 'C': (2400, 'B'), + 'D': (4800, 'B'), + 'E': (9600, 'B'), + 'F': (19200, 'B'), + # mode C & E + '0': (300, 'C'), + '1': (600, 'C'), + '2': (1200, 'C'), + '3': (2400, 'C'), + '4': (4800, 'C'), + '5': (9600, 'C'), + '6': (19200, 'C'), + } + +# TODO: cur + baudrate_id = chr(identification_message[4]) + if baudrate_id not in baudrates: + baudrate_id = '' + new_baudrate, protocol_mode = baudrates[baudrate_id] + + logger.debug(f"baudrate id is '{baudrate_id}' thus protocol mode is {protocol_mode} and suggested Baudrate is {new_baudrate} Bd") + + if chr(identification_message[5]) == '\\': + if chr(identification_message[6]) == '2': + logger.debug("HDLC protocol could be used if it was implemented") + else: + logger.debug("Another protocol could probably be used if it was implemented") + + # for protocol C or E we now send an acknowledge and include the new baudrate parameter + # maybe todo + # we could implement here a baudrate that is fixed to somewhat lower speed if we need to + # read out a smartmeter with broken communication + Action = b'0' # Data readout, possible are also b'1' for programming mode or some manufacturer specific + acknowledge = b'\x060'+ baudrate_id.encode() + Action + b'\r\n' + + if Protocol_Mode == 'C': + # the speed change in communication is initiated from the reading device + time.sleep(wait_before_acknowledge) + logger.debug(f"Using protocol mode C, send acknowledge {acknowledge} and tell smartmeter to switch to {NewBaudrate} Baud") + try: + dlms_serial.write(acknowledge) + except Exception as e: + logger.warning(f"Warning {e}") + return + time.sleep(wait_after_acknowledge) + #dlms_serial.flush() + #dlms_serial.reset_input_buffer() + if (NewBaudrate != initial_baudrate): + # change request to set higher baudrate + dlms_serial.baudrate = NewBaudrate + + elif Protocol_Mode == 'B': + # the speed change in communication is initiated from the smartmeter device + time.sleep(wait_before_acknowledge) + logger.debug(f"Using protocol mode B, smartmeter and reader will switch to {NewBaudrate} Baud") + time.sleep(wait_after_acknowledge) + #dlms_serial.flush() + #dlms_serial.reset_input_buffer() + if (NewBaudrate != initial_baudrate): + # change request to set higher baudrate + dlms_serial.baudrate = NewBaudrate + else: + logger.debug(f"No change of readout baudrate, " + "smartmeter and reader will stay at {NewBaudrate} Baud") + + # now read the huge data block with all the OBIS codes + logger.debug("Reading OBIS data from smartmeter") + response = read_data_block_from_serial(dlms_serial, None) + else: + # only listen mode, starts with / and last char is ! + # data will be in between those two + response = read_data_block_from_serial(dlms_serial, b'!', b'/') + + identification_message = str(response, 'utf-8').splitlines()[0] + + manid = identification_message[1:4] + manname = manufacturer_ids.get(manid, 'unknown') + logger.debug(f"manufacturer for {manid} is {manname} (out of {len(manufacturer_ids)} given manufacturers)") + + try: + dlms_serial.close() + except Exception: + pass + + logger.debug(f"time for reading OBIS data: {format_time(time.time() - runtime)}") + runtime = time.time() + + # Display performance of the serial communication + logger.debug(f"whole communication with smartmeter took {format_time(time.time() - starttime)}") + + if response.startswith(acknowledge): + if not only_listen: + logger.debug("acknowledge echoed from smartmeter") + response = response[len(acknowledge):] + + if use_checksum: + # data block in response may be capsuled within STX and ETX to provide error checking + # thus the response will contain a sequence of + # STX Datablock ! CR LF ETX BCC + # which means we need at least 6 characters in response where Datablock is empty + logger.debug("trying now to calculate a checksum") + + if response[0] == STX: + logger.debug("STX found") + else: + logger.warning(f"STX not found in response='{' '.join(hex(i) for i in response[:10])}...'") + + if response[-2] == ETX: + logger.debug("ETX found") + else: + logger.warning(f"ETX not found in response='...{' '.join(hex(i) for i in response[-11:])}'") + + if (len(response) > 5) and (response[0] == STX) and (response[-2] == ETX): + # perform checks (start with char after STX, end with ETX including, checksum matches last byte (BCC)) + BCC = response[-1] + logger.debug(f"block check character BCC is {BCC}") + checksum = 0 + for i in response[1:-1]: + checksum ^= i + if checksum != BCC: + logger.warning(f"checksum/protocol error: response={' '.join(hex(i) for i in response[1:-1])} " + "checksum={checksum}") + return + else: + logger.debug("checksum over data response was ok, data is valid") + else: + logger.warning("STX - ETX not found") + else: + logger.debug("checksum calculation skipped") + + if not only_listen: + if len(response) > 5: + result = str(response[1:-4], 'ascii') + logger.debug(f"parsing OBIS codes took {format_time(time.time() - runtime)}") + else: + logger.debug("response did not contain enough data for OBIS decode") + else: + result = str(response, 'ascii') + + suggested_cycle = (time.time() - starttime) + 10.0 + config['suggested_cycle'] = suggested_cycle + logger.debug(f"the whole query took {format_time(time.time() - starttime)}, suggested cycle thus is at least {format_time(suggested_cycle)}") + + if not result: + return {} + + rdict = {} # {'readout': result} + +# TODO : adjust + + _, obis = split_header(result) + + try: + for line in obis: + # Now check if we can split between values and OBIS code + arguments = line.split('(') + if len(arguments) == 1: + # no values found at all; that seems to be a wrong OBIS code line then + arguments = arguments[0] + values = "" + logger.warning(f"OBIS code line without data item: {line}") + else: + # ok, found some values to the right, lets isolate them + values = arguments[1:] + obis_code = arguments[0] + + temp_values = values + values = [] + for s in temp_values: + s = s.replace(')', '') + if len(s) > 0: + # we now should have a list with values that may contain a number + # separated from a unit by a '*' or a date + # so see, if there is an '*' within + vu = s.split('*') + if len(vu) > 2: + logger.error(f"too many '*' found in '{s}' of '{line}'") + elif len(vu) == 2: + # just a value and a unit + v = vu[0] + u = vu[1] + values.append({'value': v, 'unit': u}) + else: + # just a value, no unit + v = vu[0] + values.append({'value': v}) + # uncomment the following line to check the generation of the values dictionary + logger.debug(f"{line:40} ---> {values}") + rdict[obis_code] = values + logger.debug("finished processing lines") + except Exception as e: + logger.debug(f"error while extracting data: '{e}'") + +# end TODO + + return rdict + +# f __name__ == '__main__': +# import sys +# import argparse + +# parser = argparse.ArgumentParser(description='Query a smartmeter at a given port for DLMS output', +# usage='use "%(prog)s --help" for more information', +# formatter_class=argparse.RawTextHelpFormatter) +# parser.add_argument('port', help='specify the port to use for the smartmeter query, e.g. /dev/ttyUSB0 or /dev/dlms0') +# parser.add_argument('-v', '--verbose', help='print verbose information', action='store_true') +# parser.add_argument('-t', '--timeout', help='maximum time to wait for a message from the smartmeter', type=float, default=3.0 ) +# parser.add_argument('-b', '--baudrate', help='initial baudrate to start the communication with the smartmeter', type=int, default=300 ) +# parser.add_argument('-d', '--device', help='give a device address to include in the query', default='' ) +# parser.add_argument('-q', '--querycode', help='define alternative query code\ndefault query code is ?\nsome smartmeters provide additional information when sending\nan alternative query code, e.g. 2 instead of ?', default='?' ) +# parser.add_argument('-l', '--onlylisten', help='Only listen to serial, no active query', action='store_true' ) +# parser.add_argument('-f', '--baudrate_fix', help='Keep baudrate speed fixed', action='store_false' ) +# parser.add_argument('-c', '--nochecksum', help='use a checksum', action='store_false' ) + +# args = parser.parse_args() + +# config = {} + +# config['serial_port'] = args.port +# config['device'] = args.device +# config['querycode'] = args.querycode +# config['baudrate'] = args.baudrate +# config['baudrate_fix'] = args.baudrate_fix +# config['timeout'] = args.timeout +# config['onlylisten'] = args.onlylisten +# config['use_checksum'] = args.nochecksum + +# if args.verbose: +# logging.getLogger().setLevel(logging.DEBUG) +# ch = logging.StreamHandler() +# ch.setLevel(logging.DEBUG) +# # create formatter and add it to the handlers +# formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s @ %(lineno)d') +# #formatter = logging.Formatter('%(message)s') +# ch.setFormatter(formatter) +# # add the handlers to the logger +# logging.getLogger().addHandler(ch) +# else: +# logging.getLogger().setLevel(logging.DEBUG) +# ch = logging.StreamHandler() +# ch.setLevel(logging.DEBUG) +# # just like print +# formatter = logging.Formatter('%(message)s') +# ch.setFormatter(formatter) +# # add the handlers to the logger +# logging.getLogger().addHandler(ch) + + +# logger.info("This is DLMS Plugin running in standalone mode") +# logger.info("==============================================") + +# result = query(config) + +# if result is None: +# logger.info(f"No results from query, maybe a problem with the serial port '{config['serial_port']}' given ") +# logger.info("==============================================") +# elif len(result) > 0: +# logger.info("These are the results of the query") +# logger.info("==============================================") +# logger.info(result) +# logger.info("==============================================") +# else: +# logger.info("The query did not get any results!") +# logger.info("Maybe the serial was occupied or there was an error") + diff --git a/smartmeter/dlms_test.py b/smartmeter/dlms_test.py new file mode 100644 index 000000000..665c45b7b --- /dev/null +++ b/smartmeter/dlms_test.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 + +RESULT = """1-1:F.F(00000000) +1-1:0.0.0(97734234) +1-1:0.0.1(97734234) +1-1:0.9.1(145051) +1-1:0.9.2(241129) +1-1:0.1.2(0000) +1-1:0.1.3(241101) +1-1:0.1.0(30) +1-1:1.2.1(0501.70*kW) +1-1:1.2.2(0501.70*kW) +1-1:2.2.1(0123.46*kW) +1-1:2.2.2(0123.46*kW) +1-1:1.6.1(07.98*kW)(2411061415) +1-1:1.6.1*30(07.60)(2410101115) +1-1:1.6.1*29(06.10)(2409160830) +1-1:1.6.1*28(05.35)(2408081545) +1-1:1.6.1*27(04.11)(2407181515) +1-1:1.6.1*26(05.26)(2406041400) +1-1:1.6.1*25(06.80)(2405311000) +1-1:1.6.1*24(04.50)(2404110945) +1-1:1.6.1*23(11.15)(2403051545) +1-1:1.6.1*22(09.15)(2402211445) +1-1:1.6.1*21(08.97)(2401191030) +1-1:1.6.1*20(24.08)(2312121045) +1-1:1.6.1*19(18.56)(2311060845) +1-1:1.6.1*18(23.05)(2310241530) +1-1:1.6.1*17(20.60)(2309111330) +1-1:1.6.1*16(21.48)(2308251330) +1-1:1.6.2(07.98*kW)(2411061415) +1-1:1.6.2*30(07.60)(2410101115) +1-1:1.6.2*29(06.10)(2409160830) +1-1:1.6.2*28(05.35)(2408081545) +1-1:1.6.2*27(04.11)(2407181515) +1-1:1.6.2*26(05.26)(2406041400) +1-1:1.6.2*25(06.80)(2405311000) +1-1:1.6.2*24(04.50)(2404110945) +1-1:1.6.2*23(11.15)(2403051545) +1-1:1.6.2*22(09.15)(2402211445) +1-1:1.6.2*21(08.97)(2401191030) +1-1:1.6.2*20(24.08)(2312121045) +1-1:1.6.2*19(18.56)(2311060845) +1-1:1.6.2*18(23.05)(2310241530) +1-1:1.6.2*17(20.60)(2309111330) +1-1:1.6.2*16(21.48)(2308251330) +1-1:2.6.1(01.84*kW)(2411021345) +1-1:2.6.1*30(03.32)(2410051445) +1-1:2.6.1*29(04.35)(2409011430) +1-1:2.6.1*28(05.62)(2408311415) +1-1:2.6.1*27(06.31)(2407141445) +1-1:2.6.1*26(06.43)(2406151330) +1-1:2.6.1*25(06.15)(2405251315) +1-1:2.6.1*24(05.84)(2404211345) +1-1:2.6.1*23(04.99)(2403251400) +1-1:2.6.1*22(02.58)(2402171330) +1-1:2.6.1*21(01.35)(2401271345) +1-1:2.6.1*20(00.54)(2312251200) +1-1:2.6.1*19(00.84)(2311121315) +1-1:2.6.1*18(03.24)(2310141415) +1-1:2.6.1*17(04.43)(2309031430) +1-1:2.6.1*16(05.76)(2308031445) +1-1:2.6.2(01.84*kW)(2411021345) +1-1:2.6.2*30(03.32)(2410051445) +1-1:2.6.2*29(04.35)(2409011430) +1-1:2.6.2*28(05.62)(2408311415) +1-1:2.6.2*27(06.31)(2407141445) +1-1:2.6.2*26(06.43)(2406151330) +1-1:2.6.2*25(06.15)(2405251315) +1-1:2.6.2*24(05.84)(2404211345) +1-1:2.6.2*23(04.99)(2403251400) +1-1:2.6.2*22(02.58)(2402171330) +1-1:2.6.2*21(01.35)(2401271345) +1-1:2.6.2*20(00.54)(2312251200) +1-1:2.6.2*19(00.84)(2311121315) +1-1:2.6.2*18(03.24)(2310141415) +1-1:2.6.2*17(04.43)(2309031430) +1-1:2.6.2*16(05.76)(2308031445) +1-1:1.8.0(00043802*kWh) +1-1:1.8.0*30(00042781) +1-1:1.8.0*29(00041912) +1-1:1.8.0*28(00041227) +1-1:1.8.0*27(00040639) +1-1:1.8.0*26(00040118) +1-1:1.8.0*25(00039674) +1-1:1.8.0*24(00039139) +1-1:1.8.0*23(00038600) +1-1:1.8.0*22(00037417) +1-1:1.8.0*21(00035961) +1-1:1.8.0*20(00034776) +1-1:1.8.0*19(00032557) +1-1:1.8.0*18(00030476) +1-1:1.8.0*17(00028420) +1-1:1.8.0*16(00026978) +1-1:2.8.0(00005983*kWh) +1-1:2.8.0*30(00005979) +1-1:2.8.0*29(00005931) +1-1:2.8.0*28(00005717) +1-1:2.8.0*27(00005284) +1-1:2.8.0*26(00004705) +1-1:2.8.0*25(00004135) +1-1:2.8.0*24(00003546) +1-1:2.8.0*23(00003198) +1-1:2.8.0*22(00003075) +1-1:2.8.0*21(00003063) +1-1:2.8.0*20(00003060) +1-1:2.8.0*19(00003059) +1-1:2.8.0*18(00003056) +1-1:2.8.0*17(00003027) +1-1:2.8.0*16(00002869) +1-1:3.8.0(00021203*kvarh) +1-1:3.8.0*30(00021129) +1-1:3.8.0*29(00021035) +1-1:3.8.0*28(00020920) +1-1:3.8.0*27(00020788) +1-1:3.8.0*26(00020697) +1-1:3.8.0*25(00020622) +1-1:3.8.0*24(00020429) +1-1:3.8.0*23(00020403) +1-1:3.8.0*22(00020116) +1-1:3.8.0*21(00019929) +1-1:3.8.0*20(00019739) +1-1:3.8.0*19(00018838) +1-1:3.8.0*18(00017921) +1-1:3.8.0*17(00016923) +1-1:3.8.0*16(00016094) +1-1:4.8.0(00006222*kvarh) +1-1:4.8.0*30(00005926) +1-1:4.8.0*29(00005638) +1-1:4.8.0*28(00005404) +1-1:4.8.0*27(00005179) +1-1:4.8.0*26(00004943) +1-1:4.8.0*25(00004722) +1-1:4.8.0*24(00004526) +1-1:4.8.0*23(00004306) +1-1:4.8.0*22(00004054) +1-1:4.8.0*21(00003799) +1-1:4.8.0*20(00003550) +1-1:4.8.0*19(00003344) +1-1:4.8.0*18(00003156) +1-1:4.8.0*17(00002957) +1-1:4.8.0*16(00002771) +1-1:1.8.1(00035256*kWh) +1-1:1.8.1*30(00034502) +1-1:1.8.1*29(00033921) +1-1:1.8.1*28(00033497) +1-1:1.8.1*27(00033174) +1-1:1.8.1*26(00032943) +1-1:1.8.1*25(00032746) +1-1:1.8.1*24(00032461) +1-1:1.8.1*23(00032177) +1-1:1.8.1*22(00031377) +1-1:1.8.1*21(00030337) +1-1:1.8.1*20(00029431) +1-1:1.8.1*19(00027499) +1-1:1.8.1*18(00025699) +1-1:1.8.1*17(00023923) +1-1:1.8.1*16(00022750) +1-1:1.8.2(00008545*kWh) +1-1:1.8.2*30(00008279) +1-1:1.8.2*29(00007990) +1-1:1.8.2*28(00007730) +1-1:1.8.2*27(00007465) +1-1:1.8.2*26(00007174) +1-1:1.8.2*25(00006927) +1-1:1.8.2*24(00006678) +1-1:1.8.2*23(00006422) +1-1:1.8.2*22(00006039) +1-1:1.8.2*21(00005623) +1-1:1.8.2*20(00005344) +1-1:1.8.2*19(00005057) +1-1:1.8.2*18(00004777) +1-1:1.8.2*17(00004496) +1-1:1.8.2*16(00004227) +1-1:2.8.1(00005983*kWh) +1-1:2.8.1*30(00005979) +1-1:2.8.1*29(00005931) +1-1:2.8.1*28(00005717) +1-1:2.8.1*27(00005284) +1-1:2.8.1*26(00004705) +1-1:2.8.1*25(00004135) +1-1:2.8.1*24(00003546) +1-1:2.8.1*23(00003198) +1-1:2.8.1*22(00003075) +1-1:2.8.1*21(00003063) +1-1:2.8.1*20(00003060) +1-1:2.8.1*19(00003059) +1-1:2.8.1*18(00003056) +1-1:2.8.1*17(00003027) +1-1:2.8.1*16(00002869) +1-1:2.8.2(00000000*kWh) +1-1:2.8.2*30(00000000) +1-1:2.8.2*29(00000000) +1-1:2.8.2*28(00000000) +1-1:2.8.2*27(00000000) +1-1:2.8.2*26(00000000) +1-1:2.8.2*25(00000000) +1-1:2.8.2*24(00000000) +1-1:2.8.2*23(00000000) +1-1:2.8.2*22(00000000) +1-1:2.8.2*21(00000000) +1-1:2.8.2*20(00000000) +1-1:2.8.2*19(00000000) +1-1:2.8.2*18(00000000) +1-1:2.8.2*17(00000000) +1-1:2.8.2*16(00000000) +1-1:3.8.1(00021081*kvarh) +1-1:3.8.1*30(00021007) +1-1:3.8.1*29(00020913) +1-1:3.8.1*28(00020800) +1-1:3.8.1*27(00020679) +1-1:3.8.1*26(00020597) +1-1:3.8.1*25(00020523) +1-1:3.8.1*24(00020330) +1-1:3.8.1*23(00020304) +1-1:3.8.1*22(00020023) +1-1:3.8.1*21(00019837) +1-1:3.8.1*20(00019647) +1-1:3.8.1*19(00018746) +1-1:3.8.1*18(00017829) +1-1:3.8.1*17(00016835) +1-1:3.8.1*16(00016012) +1-1:3.8.2(00000122*kvarh) +1-1:3.8.2*30(00000122) +1-1:3.8.2*29(00000122) +1-1:3.8.2*28(00000119) +1-1:3.8.2*27(00000109) +1-1:3.8.2*26(00000099) +1-1:3.8.2*25(00000099) +1-1:3.8.2*24(00000099) +1-1:3.8.2*23(00000099) +1-1:3.8.2*22(00000092) +1-1:3.8.2*21(00000092) +1-1:3.8.2*20(00000092) +1-1:3.8.2*19(00000092) +1-1:3.8.2*18(00000092) +1-1:3.8.2*17(00000088) +1-1:3.8.2*16(00000081) +1-1:4.8.1(00003666*kvarh) +1-1:4.8.1*30(00003482) +1-1:4.8.1*29(00003302) +1-1:4.8.1*28(00003159) +1-1:4.8.1*27(00003025) +1-1:4.8.1*26(00002882) +1-1:4.8.1*25(00002746) +1-1:4.8.1*24(00002628) +1-1:4.8.1*23(00002497) +1-1:4.8.1*22(00002342) +1-1:4.8.1*21(00002182) +1-1:4.8.1*20(00002019) +1-1:4.8.1*19(00001898) +1-1:4.8.1*18(00001790) +1-1:4.8.1*17(00001678) +1-1:4.8.1*16(00001572) +1-1:4.8.2(00002555*kvarh) +1-1:4.8.2*30(00002444) +1-1:4.8.2*29(00002335) +1-1:4.8.2*28(00002245) +1-1:4.8.2*27(00002153) +1-1:4.8.2*26(00002060) +1-1:4.8.2*25(00001975) +1-1:4.8.2*24(00001897) +1-1:4.8.2*23(00001809) +1-1:4.8.2*22(00001712) +1-1:4.8.2*21(00001616) +1-1:4.8.2*20(00001530) +1-1:4.8.2*19(00001446) +1-1:4.8.2*18(00001365) +1-1:4.8.2*17(00001279) +1-1:4.8.2*16(00001198) +1-1:C.3.1(___-----) +1-1:C.3.2(__------) +1-1:C.3.3(__------) +1-1:C.3.4(--__5_--) +1-1:C.4.0(60C00183) +1-1:C.5.0(0020E0F0) +1-1:C.7.0(00000041) +1-1:0.2.0(B31) +1-1:0.2.1(005) +! +""" \ No newline at end of file diff --git a/smartmeter/get_manufacturer_ids.py b/smartmeter/get_manufacturer_ids.py new file mode 100755 index 000000000..66583f69f --- /dev/null +++ b/smartmeter/get_manufacturer_ids.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2016 - 2021 Bernd Meiners Bernd.Meiners@mail.de +######################################################################### +# +# DLMS plugin for SmartHomeNG +# +# This file is part of SmartHomeNG.py. +# Visit: https://github.com/smarthomeNG/ +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# https://smarthomeng.de +# +# SmartHomeNG.py is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG.py is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG.py. If not, see . +######################################################################### + +""" +On standalone mode this Python program will visit +https://www.dlms.com/srv/lib/Export_Flagids.php +to download the list of manufacturer ids for smartmeter as xlsx +The columns therein are labeled as "FLAG ID","Manufacturer", "Country", and "World Region" +``FLAG ID`` is exactly 3 characters long + +The result will be stored locally as ``manufacturer.yaml`` +to serve as information database for the identification of smartmeters + +""" + +import logging +import requests +import sys + +from ruamel.yaml import YAML +from io import BytesIO +install_openpyxl = "python3 -m pip install --user openpyxl" + +if __name__ == '__main__': + logger = logging.getLogger(__name__) + logger.debug(f"init standalone {__name__}") +else: + logger = logging.getLogger() + logger.debug(f"init plugin component {__name__}") + +try: + import openpyxl +except: + sys.exit(f"Package 'openpyxl' was not found. You might install with {install_openpyxl}") + + +def get_manufacturer( from_url, to_yaml, verbose = False ): + """ + Read XLSX from given url and write a yaml containing id and manufacturer + """ + # result + r = {} + y = YAML() + + logger.debug(f"Read manufacturer IDs from URL: '{url}'") + logger.debug(f"Using openpyxl version '{openpyxl.__version__}'") + + headers = {'User-agent': 'Mozilla/5.0'} + + try: + reque = requests.get(url, headers=headers) + except ConnectionError as e: + logger.debug(f"An error {e} occurred fetching {url}\n") + raise + + try: + wb = openpyxl.load_workbook(filename=BytesIO(reque.content), data_only=True) + #wb = openpyxl.load_workbook(xlfilename, data_only=True) + + logger.debug('sheetnames {}'.format(wb.sheetnames)) + + sheet = wb.active + logger.debug(f"sheet {sheet}") + logger.debug(f"rows [{sheet.min_row} .. {sheet.max_row}]") + logger.debug(f"columns [{sheet.min_column} .. {sheet.max_column}]") + + if sheet.min_row+1 <= sheet.max_row and sheet.min_column == 1 and sheet.max_column == 4: + # Get data from rows """ + for row in range(sheet.min_row+1,sheet.max_row): + id = str(sheet.cell(row, 1).value).strip() + if len(id) == 3: + # there are entries like > 'ITRON ...' < that need special cleaning: + man = str(sheet.cell(row, 2).value).strip() + man = man.strip('\'').strip() + r[id] = man + if verbose: + logger.debug(f"{id}->{man}") + else: + logger.debug(f">id< is '{id}' has more than 3 characters and will not be considered") + with open(exportfile, 'w') as f: + y.dump( r, f ) + + logger.debug(f"{len(r)} distinct manufacturers were found and written to {exportfile}") + + except Exception as e: + logger.debug(f"Error {e} occurred") + + return r + +if __name__ == '__main__': + verbose = True + + logging.getLogger().setLevel( logging.DEBUG ) + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + # create formatter and add it to the handlers + if verbose: + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s @ %(lineno)d') + else: + formatter = logging.Formatter('%(message)s') + ch.setFormatter(formatter) + # add the handlers to the logger + logging.getLogger().addHandler(ch) + logger = logging.getLogger(__name__) + + exportfile = 'manufacturer.yaml' + url = 'https://www.dlms.com/srv/lib/Export_Flagids.php' + get_manufacturer( url, exportfile, verbose) + diff --git a/smartmeter/plugin.yaml b/smartmeter/plugin.yaml new file mode 100644 index 000000000..9f267fc82 --- /dev/null +++ b/smartmeter/plugin.yaml @@ -0,0 +1,244 @@ +%YAML 1.1 +--- +plugin: + classname: Smartmeter + version: '0.0.1' # Plugin version + sh_minversion: '1.10' # minimum shNG version to use this plugin + py_minversion: '3.9' # minimum Python version to use for this plugin, due to f-strings + type: gateway # plugin type (gateway, interface, protocol, system, web) + description: + de: 'Unterstützung für Smartmeter, die DLMS (Device Language Message Specification, IEC 62056-21) oder SML (Smart Message Language) nutzen und OBIS Codes liefern' + en: 'Support for smartmeter using DLMS (Device Language Message Specification, IEC 62056-21) or SML (Smart Message Language) and delivering OBIS codes' + maintainer: Morg + tester: bmxp, onkelandy + state: develop + keywords: smartmeter ehz dlms sml obis smartmeter + multi_instance: true # plugin supports multi instance + restartable: true + # support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1002464-support-thread-für-dlms-plugin + +parameters: + protocol: + type: str + valid_list: + - DLMS + - SML + - '' + default: '' + description: + de: 'Protokoll zur Kommunikation mit dem Smartmeter. Leerer Wert bedeutet automatische Erkennung (soweit erfolgreich).' + en: 'protocol for communicating with the smartmeter. Empty value means try to detect protocol automagically.' + serialport: + type: str + description: + de: 'Serieller Port, an dem das Smartmeter angeschlossen ist' + en: 'serial port at which the smartmeter is attached' + timeout: + type: int + default: 2 + description: + de: 'Timeout in Sekunden nach dem der Lesevorgang automatisch beendet wird' + en: 'timeout in seconds for automatic abortion of readout' + baudrate: + type: int + valid_min: 50 + default: 9600 + description: + de: 'Baudrate, bei der die Kommunikation erfolgen soll' + en: 'Baudrate at which the communikation should take place' + cycle: + type: int + valid_min: 0 + default: 60 + description: + de: 'Zeitlicher Abstand zwischen zwei Abfragen des Smartmeters in Sekunden, setzen auf 0 schaltet cycle aus' + en: 'Time between two queries of smartmeter in seconds, set to zero will switch off usage of cycle' + crontab: + type: str + description: + de: 'Abfragen des Smartmeters mit Festlegung via Crontab' + en: 'Queries of smartmeter by means of a crontab' + + # DLMS parameters + baudrate_min: + type: int + valid_min: 50 + default: 300 + description: + de: 'Baudrate, bei der die Kommunikation zuerst erfolgen soll (nur DLMS)' + en: 'Baudrate at which the communication should be initiated (DLMS only)' + baudrate_fix: + type: bool + default: false + description: + de: 'Baudrate beibehalten trotz Änderungsanforderung (nur DLMS)' + en: 'Keep up baudrate in communication despite of change request' + device_address: + type: str + default: '' + description: + de: 'Interne Unteradresse des Smartmeters (nur DLMS)' + en: 'Internal subadress of smartmeter (DLMS only)' + querycode: + type: str + default: '?' + description: + de: 'Abfragecode für den Smartmeter, default ist `?`, einige Smartmeter akzeptieren z.B. `2` (nur DLMS)' + en: 'querycode of smartmeter, defaults to `?`, some smartmeter accept e.g. `2` (DLMS only)' + use_checksum: + type: bool + default: true + description: + de: 'Wenn wahr, wird eine Prüfsumme über die ausgelesenen Daten gebildet (nur DLMS)' + en: 'if true then a checksum will be calculated of the readout result' + only_listen: + type: bool + default: False + description: + de: 'Manche Smartmeter können nicht abgefragt werden sondern senden von sich aus Informationen. Für diese Smartmeter auf True setzen und die Baudrate anpassen (nur DLMS)' + en: 'Some smartmeter can not be queried, they send information without request. For those devices set to True and adjust baudrate' + + # SML parameters + buffersize: + type: int + default: 1024 + description: + de: 'Größe des Lesepuffers. Mindestens doppelte Größe der maximalen Nachrichtenlänge in Bytes (nur SML)' + en: 'Size of read buffer. At least twice the size of maximum message length (SML only)' + host: + type: str + description: + de: 'Host der eine IP Schnittstelle bereitstellt (nur SML)' + en: 'Host that provides an IP interface (SML only)' + port: + type: int + description: + de: 'Port für die Kommunikation (nur SML)' + en: 'Port for communication (SML only)' + device_type: + type: str + default: 'raw' + description: + de: 'Name des Gerätes (nur SML)' + en: 'Name of Smartmeter (SML only)' + date_offset: + type: int + default: 0 + description: + de: 'Unix timestamp der Smartmeter Inbetriebnahme (nur SML)' + en: 'Unix timestamp of Smartmeter start-up after installation (SML only)' + poly: + type: int + default: 0x1021 + description: + de: 'Polynom für die crc Berechnung (nur SML)' + en: 'Polynomial for crc calculation (SML only)' + reflect_in: + type: bool + default: true + description: + de: 'Umkehren der Bitreihenfolge für die Eingabe (nur SML)' + en: 'Reflect the octets in the input (SML only)' + xor_in: + type: int + default: 0xffff + description: + de: 'Initialer Wert für XOR Berechnung (nur SML)' + en: 'Initial value for XOR calculation (SML only)' + reflect_out: + type: bool + default: true + description: + de: 'Umkehren der Bitreihenfolge der Checksumme vor der Anwendung des XOR Wertes (nur SML)' + en: 'Reflect the octet of checksum before application of XOR value (SML only)' + xor_out: + type: int + default: 0xffff + description: + de: 'XOR Berechnung der CRC mit diesem Wert (nur SML)' + en: 'XOR final CRC value with this value (SML only)' + swap_crc_bytes: + type: bool + default: false + description: + de: 'Bytereihenfolge der berechneten Checksumme vor dem Vergleich mit der vorgegeben Checksumme tauschen (nur SML)' + en: 'Swap bytes of calculated checksum prior to comparison with given checksum (SML only)' + +item_attributes: + # Definition of item attributes defined by this plugin + obis_code: + type: str + description: + de: OBIS-Code, dessen Wert gelesen werden soll + en: obis code to be read + obis_index: + type: int + default: 0 + valid_min: 0 + valid_max: 10 + description: + de: Index des OBIS-Werts, der gelesen werden soll + en: index of the obis value to be read + obis_property: + type: str + default: value + valid_list: + - value + - unit + description: + de: > + Eigenschaft des gelesenen Wertes: + * value: gelesener Wert (siehe obis_index) + * unit: zurückgegebene Einheit des Wertes + en: > + property of the read data: + * value: read obis value (see obis_index) + * unit: read unit of value + obis_vtype: + type: str + default: '' + valid_list: + - str + - num + - int + - float + - ZST12 + - ZST10 + - D6 + - Z6 + - Z4 + - '' + description: + de: > + Konvertierungsart des gelesenen Wertes: + * str: eine Zeichenkette + * num: numerisch, entweder Ganzzahl oder Fließkommazahl + * int: Ganzzahl + * float: Fließkommazahl + * ZST12: Datum und Zeit codiert als YYMMDDhhmmss + * ZST10: Datum und Zeit codiert als YYMMDDhhmm + * D6: Datum codiert als YYMMDD + * Z4: Zeit codiert als hhmm + * Z6: Zeit codiert als hhmmss + en: > + type conversion of read value: + * str: a string + * num: a number, integer or floating point number + * int: an integer number + * float: a floating point number + * ZST12: date and time coded as YYMMDDhhmmss + * ZST10: date and time coded as YYMMDDhhmm + * D6: date coded as YYMMDD + * Z4: time coded as hhmm + * Z6: time coded as hhmmss + obis_readout: + type: str + description: + de: 'Der komplette Auslesepuffer wird für eigene Untersuchungen gespeichert' + en: 'the complete readout will be saved for own examinations' + +logic_parameters: NONE + +plugin_functions: NONE + +item_structs: NONE diff --git a/smartmeter/sml.py b/smartmeter/sml.py new file mode 100644 index 000000000..b1ec58014 --- /dev/null +++ b/smartmeter/sml.py @@ -0,0 +1,917 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2013 - 2015 KNX-User-Forum e.V. http://knx-user-forum.de/ +# Copyright 2016 - 2022 Bernd Meiners Bernd.Meiners@mail.de +# Copyright 2024 - Sebastian Helms morg @ knx-user-forum.de +######################################################################### +# +# SML plugin for SmartHomeNG +# +# This file is part of SmartHomeNG.py. +# Visit: https://github.com/smarthomeNG/ +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# https://smarthomeng.de +# +# SmartHomeNG.py is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG.py is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG.py. If not, see . +######################################################################### + + +__license__ = "GPL" +__version__ = "2.0" +__revision__ = "0.1" +__docformat__ = 'reStructuredText' + +import logging +from ruamel.yaml import YAML + +if __name__ == '__main__': + logger = logging.getLogger(__name__) + logger.debug(f"init standalone {__name__}") +else: + logger = logging.getLogger(__name__) + logger.debug(f"init plugin component {__name__}") + +import time +import serial +import re +from threading import Lock + + +def discover(config: dict) -> bool: + """ try to autodiscover SML protocol """ + return False + + +def query(config: dict) -> dict: + """ query smartmeter and return result """ + return {} + + +# +#manufacturer_ids = {} +# +#exportfile = 'manufacturer.yaml' +#try: +# with open(exportfile, 'r') as infile: +# y = YAML(typ='safe') +# manufacturer_ids = y.load(infile) +#except: +# pass +#""" +#This module implements the query of a smartmeter using the SML protocol. +#The smartmeter needs to have an infrared interface and an IR-Adapter is needed for USB. +# +#The Character Format for protocol mode A - D is defined as 1 start bit, 7 data bits, 1 parity bit, 1 stop bit and even parity +#In protocol mode E it is defined as 1 start bit, 8 data bits, 1 stop bit is allowed, see Annex E of IEC62056-21 +#For this plugin the protocol mode E is neither implemented nor supported. +# +#Abbreviations +#------------- +#COSEM +# COmpanion Specification for Energy Metering +# +#OBIS +# OBject Identification System (see iec62056-61{ed1.0}en_obis_protocol.pdf) +# +#""" +# +#SOH = 0x01 # start of header +#STX = 0x02 # start of text +#ETX = 0x03 # end of text +#ACK = 0x06 # acknowledge +#CR = 0x0D # carriage return +#LF = 0x0A # linefeed +#BCC = 0x00 # Block check Character will contain the checksum immediately following the data packet +# +# +#def format_time( timedelta ): +# """ +# returns a pretty formatted string according to the size of the timedelta +# :param timediff: time delta given in seconds +# :return: returns a string +# """ +# if timedelta > 1000.0: +# return f"{timedelta:.2f} s" +# elif timedelta > 1.0: +# return f"{timedelta:.2f} s" +# elif timedelta > 0.001: +# return f"{timedelta*1000.0:.2f} ms" +# elif timedelta > 0.000001: +# return f"{timedelta*1000000.0:.2f} µs" +# elif timedelta > 0.000000001: +# return f"{timedelta * 1000000000.0:.2f} ns" +# +# +#def read_data_block_from_serial(the_serial, end_byte=0x0a, start_byte=None, max_read_time=None): +# """ +# This function reads some bytes from serial interface +# it returns an array of bytes if a timeout occurs or a given end byte is encountered +# and otherwise None if an error occurred +# :param the_serial: interface to read from +# :param end_byte: the indicator for end of data, this will be included in response +# :param start_byte: the indicator for start of data, this will be included in response +# :param max_read_time: +# :returns the read data or None +# """ +# logger.debug("start to read data from serial device") +# response = bytes() +# starttime = time.time() +# start_found = False +# try: +# while True: +# ch = the_serial.read() +# #logger.debug(f"Read {ch}") +# runtime = time.time() +# if len(ch) == 0: +# break +# if start_byte is not None: +# if ch == start_byte: +# response = bytes() +# start_found = True +# response += ch +# if ch == end_byte: +# if start_byte is not None and not start_found: +# response = bytes() +# continue +# else: +# break +# if (response[-1] == end_byte): +# break +# if max_read_time is not None: +# if runtime-starttime > max_read_time: +# break +# except Exception as e: +# logger.debug(f"Exception {e} occurred in read data block from serial") +# return None +# logger.debug(f"finished reading data from serial device after {len(response)} bytes") +# return response +# +## +## +## moved from ehz.py +## adjust/implement +## +## +# +# # TODO: make this config dict +# self._serial = None +# self._sock = None +# self._target = None +# self._dataoffset = 0 +# +# # Lookup table for smartmeter names to data format +# _sml_devices = { +# 'smart-meter-gateway-com-1': 'hex' +# } +# +#OBIS_TYPES = ('objName', 'status', 'valTime', 'unit', 'scaler', 'value', 'signature', 'obis', 'valueReal', 'unitName', 'actualTime') +# +#SML_START_SEQUENCE = bytearray.fromhex('1B 1B 1B 1B 01 01 01 01') +#SML_END_SEQUENCE = bytearray.fromhex('1B 1B 1B 1B 1A') +# +#UNITS = { # Blue book @ http://www.dlms.com/documentation/overviewexcerptsofthedlmsuacolouredbooks/index.html +# 1: 'a', 2: 'mo', 3: 'wk', 4: 'd', 5: 'h', 6: 'min.', 7: 's', 8: '°', 9: '°C', 10: 'currency', +# 11: 'm', 12: 'm/s', 13: 'm³', 14: 'm³', 15: 'm³/h', 16: 'm³/h', 17: 'm³/d', 18: 'm³/d', 19: 'l', 20: 'kg', +# 21: 'N', 22: 'Nm', 23: 'Pa', 24: 'bar', 25: 'J', 26: 'J/h', 27: 'W', 28: 'VA', 29: 'var', 30: 'Wh', +# 31: 'WAh', 32: 'varh', 33: 'A', 34: 'C', 35: 'V', 36: 'V/m', 37: 'F', 38: 'Ω', 39: 'Ωm²/h', 40: 'Wb', +# 41: 'T', 42: 'A/m', 43: 'H', 44: 'Hz', 45: 'Rac', 46: 'Rre', 47: 'Rap', 48: 'V²h', 49: 'A²h', 50: 'kg/s', +# 51: 'Smho' +#} +# +#def init(self): +# # TODO: move this to the SML module +# # set function pointers +# if device == "hex": +# self._sml_prepare = self._sml_prepareHex +# elif device == "raw": +# self._sml_prepare = self._sml_prepareRaw +# else: +# self.logger.warning(f"Device type \"{device}\" not supported - defaulting to \"raw\"") +# self._sml_prepare = self._prepareRaw +# +# self.logger.debug(f"Using SML CRC params poly={self.poly}, reflect_in={self.reflect_in}, xor_in={self.xor_in}, reflect_out={self.reflect_out}, xor_out={self.xor_out}, swap_crc_bytes={self.swap_crc_bytes}") +# +#def connect(self): +# if not self.alive: +# self.logger.info('connect called but plugin not running.') +# return +# +# self._target = None +# with self._lock: +# try: +# if self.serialport is not None: +# self._target = f'serial://{self.serialport}' +# self._serial = serial.Serial(self.serialport, 9600, serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE, timeout=self.timeout) +# elif self.host is not None: +# self._target = f'tcp://{self.host}:{self.port}' +# self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +# self._sock.settimeout(2) +# self._sock.connect((self.host, self.port)) +# self._sock.setblocking(False) +# except Exception as e: +# self.logger.error(f'Could not connect to {self._target}: {e}') +# return +# else: +# self.logger.info(f'Connected to {self._target}') +# self.connected = True +# +#def disconnect(self): +# if self.connected: +# with self._lock: +# try: +# self._serial.close() +# except Exception: +# pass +# self._serial = None +# try: +# self._sock.shutdown(socket.SHUT_RDWR) +# except Exception: +# pass +# self._sock = None +# +# self.logger.info('SML: Disconnected!') +# self.connected = False +# self._target = None +# +# +# def _read(self, length): +# total = bytes() +# self.logger.debug('Start read') +# if self._serial is not None: +# while True: +# ch = self._serial.read() +# # self.logger.debug(f"Read {ch=}") +# if len(ch) == 0: +# self.logger.debug('End read') +# return total +# total += ch +# if len(total) >= length: +# self.logger.debug('End read') +# return total +# elif self._sock is not None: +# while True: +# try: +# data = self._sock.recv(length) +# if data: +# total.append(data) +# except socket.error as e: +# if e.args[0] == errno.EAGAIN or e.args[0] == errno.EWOULDBLOCK: +# break +# else: +# raise e +# +# self.logger.debug('End read') +# return b''.join(total) +# +# def poll_device(self): +# """ +# Polls for updates of the device, called by the scheduler. +# """ +# +# # check if another cyclic cmd run is still active +# if self._parse_lock.acquire(timeout=1): +# try: +# self.logger.debug('Polling Smartmeter now') +# +# self.connect() +# if not self.connected: +# self.logger.error('Not connected, no query possible') +# return +# else: +# self.logger.debug('Connected, try to query') +# +# start = time.time() +# data_is_valid = False +# try: +# data = self._read(self.buffersize) +# if len(data) == 0: +# self.logger.error('Reading data from device returned 0 bytes!') +# return +# else: +# self.logger.debug(f'Read {len(data)} bytes') +# +# if START_SEQUENCE in data: +# prev, _, data = data.partition(START_SEQUENCE) +# self.logger.debug('Start sequence marker {} found'.format(''.join(' {:02x}'.format(x) for x in START_SEQUENCE))) +# if END_SEQUENCE in data: +# data, _, rest = data.partition(END_SEQUENCE) +# self.logger.debug('End sequence marker {} found'.format(''.join(' {:02x}'.format(x) for x in END_SEQUENCE))) +# self.logger.debug(f'Packet size is {len(data)}') +# if len(rest) > 3: +# filler = rest[0] +# self.logger.debug(f'{filler} fill byte(s) ') +# checksum = int.from_bytes(rest[1:3], byteorder='little') +# self.logger.debug(f'Checksum is {to_Hex(checksum)}') +# buffer = bytearray() +# buffer += START_SEQUENCE + data + END_SEQUENCE + rest[0:1] +# self.logger.debug(f'Buffer length is {len(buffer)}') +# self.logger.debug('Buffer: {}'.format(''.join(' {:02x}'.format(x) for x in buffer))) +# crc16 = algorithms.Crc(width=16, poly=self.poly, reflect_in=self.reflect_in, xor_in=self.xor_in, reflect_out=self.reflect_out, xor_out=self.xor_out) +# crc_calculated = crc16.table_driven(buffer) +# if not self.swap_crc_bytes: +# self.logger.debug(f'Calculated checksum is {to_Hex(crc_calculated)}, given CRC is {to_Hex(checksum)}') +# data_is_valid = crc_calculated == checksum +# else: +# self.logger.debug(f'Calculated and swapped checksum is {to_Hex(swap16(crc_calculated))}, given CRC is {to_Hex(checksum)}') +# data_is_valid = swap16(crc_calculated) == checksum +# else: +# self.logger.debug('Not enough bytes read at end to satisfy checksum calculation') +# return +# else: +# self.logger.debug('No End sequence marker found in data') +# else: +# self.logger.debug('No Start sequence marker found in data') +# except Exception as e: +# self.logger.error(f'Reading data from {self._target} failed with exception {e}') +# return +# +# if data_is_valid: +# self.logger.debug("Checksum was ok, now parse the data_package") +# try: +# values = self._parse(self._sml_prepare(data)) +# except Exception as e: +# self.logger.error(f'Preparing and parsing data failed with exception {e}') +# else: +# for obis in values: +# self.logger.debug(f'Entry {values[obis]}') +# +# if obis in self._items: +# for prop in self._items[obis]: +# for item in self._items[obis][prop]: +# try: +# value = values[obis][prop] +# except Exception: +# pass +# else: +# item(value, self.get_shortname()) +# else: +# self.logger.debug("Checksum was not ok, will not parse the data_package") +# +# cycletime = time.time() - start +# +# self.logger.debug(f"Polling Smartmeter done. Poll cycle took {cycletime} seconds.") +# finally: +# self.disconnect() +# self._parse_lock.release() +# else: +# self.logger.warning('Triggered poll_device, but could not acquire lock. Request will be skipped.') +# +# def _parse(self, data): +# # Search SML List Entry sequences like: +# # "77 07 81 81 c7 82 03 ff 01 01 01 01 04 xx xx xx xx" - manufacturer +# # "77 07 01 00 00 00 09 ff 01 01 01 01 0b xx xx xx xx xx xx xx xx xx xx 01" - server id +# # "77 07 01 00 01 08 00 ff 63 01 80 01 62 1e 52 ff 56 00 00 00 29 85 01" - active energy consumed +# # Details see http://wiki.volkszaehler.org/software/sml +# self.values = {} +# packetsize = 7 +# self.logger.debug('Data:{}'.format(''.join(' {:02x}'.format(x) for x in data))) +# self._dataoffset = 0 +# while self._dataoffset < len(data)-packetsize: +# +# # Find SML_ListEntry starting with 0x77 0x07 +# # Attention! The check for != 0xff was necessary because of a possible Client-ID set to 77 07 ff ff ff ff ff ff +# # which would be accidently interpreted as an OBIS value +# if data[self._dataoffset] == 0x77 and data[self._dataoffset+1] == 0x07 and data[self._dataoffset+2] != 0xff: +# packetstart = self._dataoffset +# self._dataoffset += 1 +# try: +# entry = { +# 'objName' : self._read_entity(data), +# 'status' : self._read_entity(data), +# 'valTime' : self._read_entity(data), +# 'unit' : self._read_entity(data), +# 'scaler' : self._read_entity(data), +# 'value' : self._read_entity(data), +# 'signature' : self._read_entity(data) +# } +# +# # Decoding status information if present +# if entry['status'] is not None: +# entry['statRun'] = True if ((entry['status'] >> 8) & 1) == 1 else False # True: meter is counting, False: standstill +# entry['statFraudMagnet'] = True if ((entry['status'] >> 8) & 2) == 2 else False # True: magnetic manipulation detected, False: ok +# entry['statFraudCover'] = True if ((entry['status'] >> 8) & 4) == 4 else False # True: cover manipulation detected, False: ok +# entry['statEnergyTotal'] = True if ((entry['status'] >> 8) & 8) == 8 else False # Current flow total. True: -A, False: +A +# entry['statEnergyL1'] = True if ((entry['status'] >> 8) & 16) == 16 else False # Current flow L1. True: -A, False: +A +# entry['statEnergyL2'] = True if ((entry['status'] >> 8) & 32) == 32 else False # Current flow L2. True: -A, False: +A +# entry['statEnergyL3'] = True if ((entry['status'] >> 8) & 64) == 64 else False # Current flow L3. True: -A, False: +A +# entry['statRotaryField'] = True if ((entry['status'] >> 8) & 128) == 128 else False # True: rotary field not L1->L2->L3, False: ok +# entry['statBackstop'] = True if ((entry['status'] >> 8) & 256) == 256 else False # True: backstop active, False: backstop not active +# entry['statCalFault'] = True if ((entry['status'] >> 8) & 512) == 512 else False # True: calibration relevant fatal fault, False: ok +# entry['statVoltageL1'] = True if ((entry['status'] >> 8) & 1024) == 1024 else False # True: Voltage L1 present, False: not present +# entry['statVoltageL2'] = True if ((entry['status'] >> 8) & 2048) == 2048 else False # True: Voltage L2 present, False: not present +# entry['statVoltageL3'] = True if ((entry['status'] >> 8) & 4096) == 4096 else False # True: Voltage L3 present, False: not present +# +# # Add additional calculated fields +# entry['obis'] = f"{entry['objName'][0]}-{entry['objName'][1]}:{entry['objName'][2]}.{entry['objName'][3]}.{entry['objName'][4]}*{entry['objName'][5]}" +# entry['valueReal'] = round(entry['value'] * 10 ** entry['scaler'], 1) if entry['scaler'] is not None else entry['value'] +# entry['unitName'] = UNITS[entry['unit']] if entry['unit'] is not None and entry['unit'] in UNITS else None +# entry['actualTime'] = time.ctime(self.date_offset + entry['valTime'][1]) if entry['valTime'] is not None else None # Decodes valTime into date/time string +# # For a Holley DTZ541 with faulty Firmware remove the ^[1] from this line ^. +# +# # Convert some special OBIS values into nicer format +# # EMH ED300L: add additional OBIS codes +# if entry['obis'] == '1-0:0.2.0*0': +# entry['valueReal'] = entry['value'].decode() # Firmware as UTF-8 string +# if entry['obis'] == '1-0:96.50.1*1' or entry['obis'] == '129-129:199.130.3*255': +# entry['valueReal'] = entry['value'].decode() # Manufacturer code as UTF-8 string +# if entry['obis'] == '1-0:96.1.0*255' or entry['obis'] == '1-0:0.0.9*255': +# entry['valueReal'] = entry['value'].hex() # ServerID (Seriel Number) as hex string as found on frontpanel +# if entry['obis'] == '1-0:96.5.0*255': +# entry['valueReal'] = bin(entry['value'] >> 8) # Status as binary string, so not decoded into status bits as above +# +# entry['objName'] = entry['obis'] # Changes objName for DEBUG output to nicer format +# +# self.values[entry['obis']] = entry +# +# except Exception as e: +# if self._dataoffset < len(data) - 1: +# self.logger.warning('Cannot parse entity at position {}, byte {}: {}:{}...'.format(self._dataoffset, self._dataoffset - packetstart, e, ''.join(' {:02x}'.format(x) for x in data[packetstart:packetstart+64]))) +# self._dataoffset = packetstart + packetsize - 1 +# else: +# self._dataoffset += 1 +# +# return self.values +# +# def _read_entity(self, data): +# import builtins +# upack = { +# 5: {1: '>b', 2: '>h', 4: '>i', 8: '>q'}, # int +# 6: {1: '>B', 2: '>H', 4: '>I', 8: '>Q'} # uint +# } +# +# result = None +# +# tlf = data[self._dataoffset] +# type = (tlf & 112) >> 4 +# more = tlf & 128 +# len = tlf & 15 +# self._dataoffset += 1 +# +# if more > 0: +# tlf = data[self._dataoffset] +# len = (len << 4) + (tlf & 15) +# self._dataoffset += 1 +# +# len -= 1 +# +# if len == 0: # Skip empty optional value +# return result +# +# if self._dataoffset + len >= builtins.len(data): +# raise Exception(f"Try to read {len} bytes, but only got {builtins.len(data) - self._dataoffset}") +# +# if type == 0: # Octet string +# result = data[self._dataoffset:self._dataoffset+len] +# +# elif type == 5 or type == 6: # int or uint +# d = data[self._dataoffset:self._dataoffset+len] +# +# ulen = len +# if ulen not in upack[type]: # Extend to next greather unpack unit +# while ulen not in upack[type]: +# d = b'\x00' + d +# ulen += 1 +# +# result = struct.unpack(upack[type][ulen], d)[0] +# +# elif type == 7: # list +# result = [] +# self._dataoffset += 1 +# for i in range(0, len + 1): +# result.append(self._read_entity(data)) +# return result +# +# else: +# self.logger.warning(f'Skipping unknown field {hex(tlf)}') +# +# self._dataoffset += len +# +# return result +# +# def _prepareRaw(self, data): +# return data +# +# def _prepareHex(self, data): +# data = data.decode("iso-8859-1").lower() +# data = re.sub("[^a-f0-9]", " ", data) +# data = re.sub("( +[a-f0-9]|[a-f0-9] +)", "", data) +# data = data.encode() +# return bytes(''.join(chr(int(data[i:i+2], 16)) for i in range(0, len(data), 2)), "iso8859-1") +# +# +########################################################### +## Helper Functions +########################################################### +# +# +#def to_Hex(data): +# """ +# Returns the hex representation of the given data +# """ +# # try: +# # return data.hex() +# # except: +# # return "".join("%02x " % b for b in data).rstrip() +# # logger.debug("Hextype: {}".format(type(data))) +# if isinstance(data, int): +# return hex(data) +# +# return "".join("%02x " % b for b in data).rstrip() +# +# +#def swap16(x): +# return (((x << 8) & 0xFF00) | +# ((x >> 8) & 0x00FF)) +# +# +#def swap32(x): +# return (((x << 24) & 0xFF000000) | +# ((x << 8) & 0x00FF0000) | +# ((x >> 8) & 0x0000FF00) | +# ((x >> 24) & 0x000000FF)) +# +## +## +## +## +## +## +# +# +#def query( config ): +# """ +# This function will +# 1. open a serial communication line to the smartmeter +# 2. sends a request for info +# 3. parses the devices first (and maybe second) answer for capabilities of the device +# 4. adjusts the speed of the communication accordingly +# 5. reads out the block of OBIS information +# 6. closes the serial communication +# +# config contains a dict with entries for +# 'serialport', 'device', 'querycode', 'baudrate', 'baudrate_fix', 'timeout', 'onlylisten', 'use_checksum' +# +# return: a textblock with the data response from smartmeter +# """ +# # for the performance of the serial read we need to save the actual time +# starttime = time.time() +# runtime = starttime +# result = None +# +# +# SerialPort = config.get('serialport') +# Device = config.get('device','') +# InitialBaudrate = config.get('baudrate', 300) +# QueryCode = config.get('querycode', '?') +# use_checksum = config.get('use_checksum', True) +# baudrate_fix = config.get('baudrate_fix', False) +# timeout = config.get('timeout', 3) +# OnlyListen = config.get('onlylisten', False) # just for the case that smartmeter transmits data without a query first +# logger.debug(f"Config='{config}'") +# StartChar = b'/'[0] +# +# Request_Message = b"/"+QueryCode.encode('ascii')+Device.encode('ascii')+b"!\r\n" +# +# +# # open the serial communication +# # about timeout: time tr between sending a request and an answer needs to be +# # 200ms < tr < 1500ms for protocol mode A or B +# # inter character time must be smaller than 1500 ms +# # The time between the reception of a message and the transmission of an answer is: +# # (20 ms) 200 ms = tr = 1 500 ms (see item 12) of 6.3.14). +# # If a response has not been received, the waiting time of the transmitting equipment after +# # transmission of the identification message, before it continues with the transmission, is: +# # 1 500 ms < tt = 2 200 ms +# # The time between two characters in a character sequence is: +# # ta < 1 500 ms +# wait_before_acknowledge = 0.4 # wait for 400 ms before sending the request to change baudrate +# wait_after_acknowledge = 0.4 # wait for 400 ms after sending acknowledge +# sml_serial = None +# +# try: +# sml_serial = serial.Serial(SerialPort, +# InitialBaudrate, +# bytesize=serial.SEVENBITS, +# parity=serial.PARITY_EVEN, +# stopbits=serial.STOPBITS_ONE, +# timeout=timeout) +# if not SerialPort == sml_serial.name: +# logger.debug(f"Asked for {SerialPort} as serial port, but really using now {sml_serial.name}") +# +# except FileNotFoundError as e: +# logger.error(f"Serial port '{SerialPort}' does not exist, please check your port") +# return +# except OSError as e: +# logger.error(f"Serial port '{SerialPort}' does not exist, please check the spelling") +# return +# except serial.SerialException as e: +# if sml_serial is None: +# logger.error(f"Serial port '{SerialPort}' could not be opened") +# else: +# logger.error(f"Serial port '{SerialPort}' could be opened but somehow not accessed") +# except Exception as e: +# logger.error(f"Another unknown error occurred: '{e}'") +# return +# +# if not sml_serial.isOpen(): +# logger.error(f"Serial port '{SerialPort}' could not be opened with given parameters, maybe wrong baudrate?") +# return +# +# logger.debug(f"Time to open serial port {SerialPort}: {format_time(time.time()- runtime)}") +# runtime = time.time() +# +# Acknowledge = b'' # preset empty answer +# +# if not OnlyListen: +# # start a dialog with smartmeter +# try: +# #logger.debug(f"Reset input buffer from serial port '{SerialPort}'") +# #sml_serial.reset_input_buffer() # replaced sml_serial.flushInput() +# logger.debug(f"Writing request message {Request_Message} to serial port '{SerialPort}'") +# sml_serial.write(Request_Message) +# #logger.debug(f"Flushing buffer from serial port '{SerialPort}'") +# #sml_serial.flush() # replaced sml_serial.drainOutput() +# except Exception as e: +# logger.warning(f"Error {e}") +# return +# +# logger.debug(f"Time to send first request to smartmeter: {format_time(time.time()- runtime)}") +# +# # now get first response +# response = read_data_block_from_serial(sml_serial) +# if response is None: +# logger.debug("No response received upon first request") +# return +# +# logger.debug(f"Time to receive an answer: {format_time(time.time()- runtime)}") +# runtime = time.time() +# +# # We need to examine the read response here for an echo of the _Request_Message +# # some meters answer with an echo of the request Message +# if response == Request_Message: +# logger.debug("Request Message was echoed, need to read the identification message") +# # read Identification message if Request was echoed +# # now read the capabilities and type/brand line from Smartmeter +# # e.g. b'/LGZ5\\2ZMD3104407.B32\r\n' +# response = read_data_block_from_serial(sml_serial) +# else: +# logger.debug("Request Message was not equal to response, treating as identification message") +# +# logger.debug(f"Time to get first identification message from smartmeter: {format_time(time.time() - runtime)}") +# runtime = time.time() +# +# Identification_Message = response +# logger.debug(f"Identification Message is {Identification_Message}") +# +# # need at least 7 bytes: +# # 1 byte "/" +# # 3 bytes short Identification +# # 1 byte speed indication +# # 2 bytes CR LF +# if (len(Identification_Message) < 7): +# logger.warning(f"malformed identification message: '{Identification_Message}', abort query") +# return +# +# if (Identification_Message[0] != StartChar): +# logger.warning(f"identification message '{Identification_Message}' does not start with '/', abort query") +# return +# +# manid = str(Identification_Message[1:4],'utf-8') +# manname = manufacturer_ids.get(manid,'unknown') +# logger.debug(f"The manufacturer for {manid} is {manname} (out of {len(manufacturer_ids)} given manufacturers)") +# +# """ +# Different smartmeters allow for different protocol modes. +# The protocol mode decides whether the communication is fixed to a certain baudrate or might be speed up. +# Some meters do initiate a protocol by themselves with a fixed speed of 2400 baud e.g. Mode D +# However some meters specify a speed of 9600 Baud although they use protocol mode D (readonly) +# """ +# Protocol_Mode = 'A' +# +# """ +# The communication of the plugin always stays at the same speed, +# Protocol indicator can be anything except for A-I, 0-9, /, ? +# """ +# Baudrates_Protocol_Mode_A = 300 +# Baudrates_Protocol_Mode_B = { 'A': 600, 'B': 1200, 'C': 2400, 'D': 4800, 'E': 9600, 'F': 19200, +# 'G': "reserved", 'H': "reserved", 'I': "reserved" } +# Baudrates_Protocol_Mode_C = { '0': 300, '1': 600, '2': 1200, '3': 2400, '4': 4800, '5': 9600, '6': 19200, +# '7': "reserved", '8': "reserved", '9': "reserved"} +# +# # always '3' but it is always initiated by the metering device so it can't be encountered here +# Baudrates_Protocol_Mode_D = { '3' : 2400} +# Baudrates_Protocol_Mode_E = Baudrates_Protocol_Mode_C +# +# Baudrate_identification = chr(Identification_Message[4]) +# if Baudrate_identification in Baudrates_Protocol_Mode_B: +# NewBaudrate = Baudrates_Protocol_Mode_B[Baudrate_identification] +# Protocol_Mode = 'B' +# elif Baudrate_identification in Baudrates_Protocol_Mode_C: +# NewBaudrate = Baudrates_Protocol_Mode_C[Baudrate_identification] +# Protocol_Mode = 'C' # could also be 'E' but it doesn't make any difference here +# else: +# NewBaudrate = Baudrates_Protocol_Mode_A +# Protocol_Mode = 'A' +# +# logger.debug(f"Baudrate id is '{Baudrate_identification}' thus Protocol Mode is {Protocol_Mode} and suggested Baudrate is {NewBaudrate} Bd") +# +# if chr(Identification_Message[5]) == '\\': +# if chr(Identification_Message[6]) == '2': +# logger.debug("HDLC protocol could be used if it was implemented") +# else: +# logger.debug("Another protocol could probably be used if it was implemented") +# +# # for protocol C or E we now send an acknowledge and include the new baudrate parameter +# # maybe todo +# # we could implement here a baudrate that is fixed to somewhat lower speed if we need to +# # read out a smartmeter with broken communication +# Action = b'0' # Data readout, possible are also b'1' for programming mode or some manufacturer specific +# Acknowledge = b'\x060'+ Baudrate_identification.encode() + Action + b'\r\n' +# +# if Protocol_Mode == 'C': +# # the speed change in communication is initiated from the reading device +# time.sleep(wait_before_acknowledge) +# logger.debug(f"Using protocol mode C, send acknowledge {Acknowledge} and tell smartmeter to switch to {NewBaudrate} Baud") +# try: +# sml_serial.write( Acknowledge ) +# except Exception as e: +# logger.warning(f"Warning {e}") +# return +# time.sleep(wait_after_acknowledge) +# #sml_serial.flush() +# #sml_serial.reset_input_buffer() +# if (NewBaudrate != InitialBaudrate): +# # change request to set higher baudrate +# sml_serial.baudrate = NewBaudrate +# +# elif Protocol_Mode == 'B': +# # the speed change in communication is initiated from the smartmeter device +# time.sleep(wait_before_acknowledge) +# logger.debug(f"Using protocol mode B, smartmeter and reader will switch to {NewBaudrate} Baud") +# time.sleep(wait_after_acknowledge) +# #sml_serial.flush() +# #sml_serial.reset_input_buffer() +# if (NewBaudrate != InitialBaudrate): +# # change request to set higher baudrate +# sml_serial.baudrate = NewBaudrate +# else: +# logger.debug(f"No change of readout baudrate, " +# "smartmeter and reader will stay at {NewBaudrate} Baud") +# +# # now read the huge data block with all the OBIS codes +# logger.debug("Reading OBIS data from smartmeter") +# response = read_data_block_from_serial(sml_serial, None) +# else: +# # only listen mode, starts with / and last char is ! +# # data will be in between those two +# response = read_data_block_from_serial(sml_serial, b'!', b'/') +# +# Identification_Message = str(response,'utf-8').splitlines()[0] +# +# manid = Identification_Message[1:4] +# manname = manufacturer_ids.get(manid,'unknown') +# logger.debug(f"The manufacturer for {manid} is {manname} (out of {len(manufacturer_ids)} given manufacturers)") +# +# +# sml_serial.close() +# logger.debug(f"Time for reading OBIS data: {format_time(time.time()- runtime)}") +# runtime = time.time() +# +# # Display performance of the serial communication +# logger.debug(f"Whole communication with smartmeter took {format_time(time.time() - starttime)}") +# +# if response.startswith(Acknowledge): +# if not OnlyListen: +# logger.debug("Acknowledge echoed from smartmeter") +# response = response[len(Acknowledge):] +# +# if use_checksum: +# # data block in response may be capsuled within STX and ETX to provide error checking +# # thus the response will contain a sequence of +# # STX Datablock ! CR LF ETX BCC +# # which means we need at least 6 characters in response where Datablock is empty +# logger.debug("trying now to calculate a checksum") +# +# if response[0] == STX: +# logger.debug("STX found") +# else: +# logger.warning(f"STX not found in response='{' '.join(hex(i) for i in response[:10])}...'") +# +# if response[-2] == ETX: +# logger.debug("ETX found") +# else: +# logger.warning(f"ETX not found in response='...{' '.join(hex(i) for i in response[-11])}'") +# +# if (len(response) > 5) and (response[0] == STX) and (response[-2] == ETX): +# # perform checks (start with char after STX, end with ETX including, checksum matches last byte (BCC)) +# BCC = response[-1] +# logger.debug(f"block check character BCC is {BCC}") +# checksum = 0 +# for i in response[1:-1]: +# checksum ^= i +# if checksum != BCC: +# logger.warning(f"checksum/protocol error: response={' '.join(hex(i) for i in response[1:-1])} " +# "checksum={checksum}") +# return +# else: +# logger.debug("checksum over data response was ok, data is valid") +# else: +# logger.warning("STX - ETX not found") +# else: +# logger.debug("checksum calculation skipped") +# +# if not OnlyListen: +# if len(response) > 5: +# result = str(response[1:-4], 'ascii') +# logger.debug(f"parsing OBIS codes took {format_time(time.time()- runtime)}") +# else: +# logger.debug("Sorry response did not contain enough data for OBIS decode") +# else: +# result = str(response, 'ascii') +# +# suggested_cycle = (time.time() - starttime) + 10.0 +# config['suggested_cycle'] = suggested_cycle +# logger.debug(f"the whole query took {format_time(time.time()- starttime)}, suggested cycle thus is at least {format_time(suggested_cycle)}") +# return result +# +#if __name__ == '__main__': +# import sys +# import argparse +# +# parser = argparse.ArgumentParser(description='Query a smartmeter at a given port for SML output', +# usage='use "%(prog)s --help" for more information', +# formatter_class=argparse.RawTextHelpFormatter) +# parser.add_argument('port', help='specify the port to use for the smartmeter query, e.g. /dev/ttyUSB0 or /dev/sml0') +# parser.add_argument('-v', '--verbose', help='print verbose information', action='store_true') +# parser.add_argument('-t', '--timeout', help='maximum time to wait for a message from the smartmeter', type=float, default=3.0 ) +# parser.add_argument('-b', '--baudrate', help='initial baudrate to start the communication with the smartmeter', type=int, default=300 ) +# parser.add_argument('-d', '--device', help='give a device address to include in the query', default='' ) +# parser.add_argument('-q', '--querycode', help='define alternative query code\ndefault query code is ?\nsome smartmeters provide additional information when sending\nan alternative query code, e.g. 2 instead of ?', default='?' ) +# parser.add_argument('-l', '--onlylisten', help='Only listen to serial, no active query', action='store_true' ) +# parser.add_argument('-f', '--baudrate_fix', help='Keep baudrate speed fixed', action='store_false' ) +# parser.add_argument('-c', '--nochecksum', help='use a checksum', action='store_false' ) +# +# args = parser.parse_args() +# +# config = {} +# +# config['serialport'] = args.port +# config['device'] = args.device +# config['querycode'] = args.querycode +# config['baudrate'] = args.baudrate +# config['baudrate_fix'] = args.baudrate_fix +# config['timeout'] = args.timeout +# config['onlylisten'] = args.onlylisten +# config['use_checksum'] = args.nochecksum +# +# if args.verbose: +# logging.getLogger().setLevel( logging.DEBUG ) +# ch = logging.StreamHandler() +# ch.setLevel(logging.DEBUG) +# # create formatter and add it to the handlers +# formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s @ %(lineno)d') +# #formatter = logging.Formatter('%(message)s') +# ch.setFormatter(formatter) +# # add the handlers to the logger +# logging.getLogger().addHandler(ch) +# else: +# logging.getLogger().setLevel( logging.DEBUG ) +# ch = logging.StreamHandler() +# ch.setLevel(logging.DEBUG) +# # just like print +# formatter = logging.Formatter('%(message)s') +# ch.setFormatter(formatter) +# # add the handlers to the logger +# logging.getLogger().addHandler(ch) +# +# +# logger.info("This is SML Plugin running in standalone mode") +# logger.info("==============================================") +# +# result = query(config) +# +# if result is None: +# logger.info(f"No results from query, maybe a problem with the serial port '{config['serialport']}' given ") +# logger.info("==============================================") +# elif len(result) > 0: +# logger.info("These are the results of the query") +# logger.info("==============================================") +# logger.info(result) +# logger.info("==============================================") +# else: +# logger.info("The query did not get any results!") +# logger.info("Maybe the serial was occupied or there was an error") +# +# \ No newline at end of file From 0bc68d767578cbda78ba483df9513863f4e9cda4 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:34:34 +0100 Subject: [PATCH 02/34] smartmeter: first iteration of DLMS part finished, no webif or SML yet --- smartmeter/__init__.py | 19 ++- smartmeter/{algorithms.py => crc.py} | 28 +++-- smartmeter/dlms.py | 177 +++++++++++++-------------- smartmeter/get_manufacturer_ids.py | 34 +++-- 4 files changed, 122 insertions(+), 136 deletions(-) rename smartmeter/{algorithms.py => crc.py} (95%) diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index 83f7395ac..6c5c32cdb 100644 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -201,9 +201,6 @@ def run(self): if not self._protocol: # TODO: call DLMS/SML discovery routines to find protocol -# -# TODO: module.discover needs to probe for communication and return True if succeeded -# if sml.discover(self._config): self._protocol = 'SML' elif dlms.discover(self._config): @@ -289,14 +286,14 @@ def poll_device(self): if self._lock.acquire(blocking=False): self.logger.debug('lock acquired') try: -# -# module.query needs to return a dict: -# { -# 'readout': '', (only for dlms?) -# 'obis1': [{'value': val0, optional 'unit': unit1}, {'value': val1, optional 'unit': unit1'}] -# 'obis2': [{...}] -# } -# + # + # module.query needs to return a dict: + # { + # 'readout': '', (only for dlms?) + # 'obis1': [{'value': val0, optional 'unit': unit1}, {'value': val1, optional 'unit': unit1'}] + # 'obis2': [{...}] + # } + # result = self._get_module().query(self._config) if not result: self.logger.warning('no results from smartmeter query received') diff --git a/smartmeter/algorithms.py b/smartmeter/crc.py similarity index 95% rename from smartmeter/algorithms.py rename to smartmeter/crc.py index 3ebcebd53..3e2ecfe68 100755 --- a/smartmeter/algorithms.py +++ b/smartmeter/crc.py @@ -45,13 +45,26 @@ print("{0:#x}".format(crc.table_driven("123456789"))) """ + class Crc(object): """ A base class for CRC routines. + + Default parameters are set to necessary CRC16 values for SML calculations """ # pylint: disable=too-many-instance-attributes - def __init__(self, width, poly, reflect_in, xor_in, reflect_out, xor_out, table_idx_width=None, slice_by=1): + def __init__( + self, + width: int = 16, + poly: int = 0x1021, + reflect_in: bool = True, + xor_in: int = 0xffff, + reflect_out: bool = True, + xor_out: int = 0xffff, + table_idx_width: int = 8, + slice_by: int = 1 + ): """The Crc constructor. The parameters are as follows: @@ -75,11 +88,7 @@ def __init__(self, width, poly, reflect_in, xor_in, reflect_out, xor_out, table_ self.msb_mask = 0x1 << (self.width - 1) self.mask = ((self.msb_mask - 1) << 1) | 1 - if self.tbl_idx_width != None: - self.tbl_width = 1 << self.tbl_idx_width - else: - self.tbl_idx_width = 8 - self.tbl_width = 1 << self.tbl_idx_width + self.tbl_width = 1 << self.tbl_idx_width self.direct_init = self.xor_in self.nondirect_init = self.__get_nondirect_init(self.xor_in) @@ -88,7 +97,6 @@ def __init__(self, width, poly, reflect_in, xor_in, reflect_out, xor_out, table_ else: self.crc_shift = 0 - def __get_nondirect_init(self, init): """ return the non-direct init if the direct algorithm has been selected. @@ -103,7 +111,6 @@ def __get_nondirect_init(self, init): crc |= self.msb_mask return crc & self.mask - def reflect(self, data, width): """ reflect a data word, i.e. reverts the bit order. @@ -116,7 +123,6 @@ def reflect(self, data, width): res = (res << 1) | (data & 0x01) return res - def bit_by_bit(self, in_data): """ Classic simple and slow CRC implementation. This function iterates bit @@ -147,7 +153,6 @@ def bit_by_bit(self, in_data): reg = self.reflect(reg, self.width) return (reg ^ self.xor_out) & self.mask - def bit_by_bit_fast(self, in_data): """ This is a slightly modified version of the bit-by-bit algorithm: it @@ -174,7 +179,6 @@ def bit_by_bit_fast(self, in_data): reg = self.reflect(reg, self.width) return reg ^ self.xor_out - def gen_table(self): """ This function generates the CRC table used for the table_driven CRC @@ -203,7 +207,6 @@ def gen_table(self): tbl[j][i] = (tbl[j - 1][i] >> 8) ^ tbl[0][tbl[j - 1][i] & 0xff] return tbl - def table_driven(self, in_data): """ The Standard table_driven CRC algorithm. @@ -232,4 +235,3 @@ def table_driven(self, in_data): if self.reflect_out: reg = self.reflect(reg, self.width) return reg ^ self.xor_out - diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py index 4aa0a06f1..8e8afaa67 100755 --- a/smartmeter/dlms.py +++ b/smartmeter/dlms.py @@ -34,10 +34,8 @@ __docformat__ = 'reStructuredText' import logging -import datetime import time import serial -import re from ruamel.yaml import YAML @@ -391,14 +389,14 @@ def query(config) -> dict: # Some meters do initiate a protocol by themselves with a fixed speed of 2400 baud e.g. Mode D # However some meters specify a speed of 9600 Baud although they use protocol mode D (readonly) # - # Protocol_Mode = 'A' + # protocol_mode = 'A' # # The communication of the plugin always stays at the same speed, # Protocol indicator can be anything except for A-I, 0-9, /, ? # baudrates = { # mode A - '': 300, + '': (300, 'A'), # mode B 'A': (600, 'B'), 'B': (1200, 'B'), @@ -416,7 +414,6 @@ def query(config) -> dict: '6': (19200, 'C'), } -# TODO: cur baudrate_id = chr(identification_message[4]) if baudrate_id not in baudrates: baudrate_id = '' @@ -428,48 +425,47 @@ def query(config) -> dict: if chr(identification_message[6]) == '2': logger.debug("HDLC protocol could be used if it was implemented") else: - logger.debug("Another protocol could probably be used if it was implemented") + logger.debug(f"another protocol could probably be used if it was implemented, id is {identification_message[6]}") # for protocol C or E we now send an acknowledge and include the new baudrate parameter # maybe todo # we could implement here a baudrate that is fixed to somewhat lower speed if we need to # read out a smartmeter with broken communication - Action = b'0' # Data readout, possible are also b'1' for programming mode or some manufacturer specific - acknowledge = b'\x060'+ baudrate_id.encode() + Action + b'\r\n' + action = b'0' # Data readout, possible are also b'1' for programming mode or some manufacturer specific + acknowledge = b'\x060' + baudrate_id.encode() + action + b'\r\n' - if Protocol_Mode == 'C': + if protocol_mode == 'C': # the speed change in communication is initiated from the reading device time.sleep(wait_before_acknowledge) - logger.debug(f"Using protocol mode C, send acknowledge {acknowledge} and tell smartmeter to switch to {NewBaudrate} Baud") + logger.debug(f"using protocol mode C, send acknowledge {acknowledge} and tell smartmeter to switch to {new_baudrate} baud") try: dlms_serial.write(acknowledge) except Exception as e: - logger.warning(f"Warning {e}") - return + logger.warning(f"error on sending baudrate change: {e}") + return {} time.sleep(wait_after_acknowledge) - #dlms_serial.flush() - #dlms_serial.reset_input_buffer() - if (NewBaudrate != initial_baudrate): + # dlms_serial.flush() + # dlms_serial.reset_input_buffer() + if (new_baudrate != initial_baudrate): # change request to set higher baudrate - dlms_serial.baudrate = NewBaudrate + dlms_serial.baudrate = new_baudrate - elif Protocol_Mode == 'B': + elif protocol_mode == 'B': # the speed change in communication is initiated from the smartmeter device time.sleep(wait_before_acknowledge) - logger.debug(f"Using protocol mode B, smartmeter and reader will switch to {NewBaudrate} Baud") + logger.debug(f"using protocol mode B, smartmeter and reader will switch to {new_baudrate} baud") time.sleep(wait_after_acknowledge) - #dlms_serial.flush() - #dlms_serial.reset_input_buffer() - if (NewBaudrate != initial_baudrate): + # dlms_serial.flush() + # dlms_serial.reset_input_buffer() + if (new_baudrate != initial_baudrate): # change request to set higher baudrate - dlms_serial.baudrate = NewBaudrate + dlms_serial.baudrate = new_baudrate else: - logger.debug(f"No change of readout baudrate, " - "smartmeter and reader will stay at {NewBaudrate} Baud") + logger.debug(f"no change of readout baudrate, smartmeter and reader will stay at {new_baudrate} baud") # now read the huge data block with all the OBIS codes logger.debug("Reading OBIS data from smartmeter") - response = read_data_block_from_serial(dlms_serial, None) + response = read_data_block_from_serial(dlms_serial, b'') else: # only listen mode, starts with / and last char is ! # data will be in between those two @@ -595,75 +591,68 @@ def query(config) -> dict: except Exception as e: logger.debug(f"error while extracting data: '{e}'") -# end TODO - return rdict -# f __name__ == '__main__': -# import sys -# import argparse - -# parser = argparse.ArgumentParser(description='Query a smartmeter at a given port for DLMS output', -# usage='use "%(prog)s --help" for more information', -# formatter_class=argparse.RawTextHelpFormatter) -# parser.add_argument('port', help='specify the port to use for the smartmeter query, e.g. /dev/ttyUSB0 or /dev/dlms0') -# parser.add_argument('-v', '--verbose', help='print verbose information', action='store_true') -# parser.add_argument('-t', '--timeout', help='maximum time to wait for a message from the smartmeter', type=float, default=3.0 ) -# parser.add_argument('-b', '--baudrate', help='initial baudrate to start the communication with the smartmeter', type=int, default=300 ) -# parser.add_argument('-d', '--device', help='give a device address to include in the query', default='' ) -# parser.add_argument('-q', '--querycode', help='define alternative query code\ndefault query code is ?\nsome smartmeters provide additional information when sending\nan alternative query code, e.g. 2 instead of ?', default='?' ) -# parser.add_argument('-l', '--onlylisten', help='Only listen to serial, no active query', action='store_true' ) -# parser.add_argument('-f', '--baudrate_fix', help='Keep baudrate speed fixed', action='store_false' ) -# parser.add_argument('-c', '--nochecksum', help='use a checksum', action='store_false' ) - -# args = parser.parse_args() - -# config = {} - -# config['serial_port'] = args.port -# config['device'] = args.device -# config['querycode'] = args.querycode -# config['baudrate'] = args.baudrate -# config['baudrate_fix'] = args.baudrate_fix -# config['timeout'] = args.timeout -# config['onlylisten'] = args.onlylisten -# config['use_checksum'] = args.nochecksum - -# if args.verbose: -# logging.getLogger().setLevel(logging.DEBUG) -# ch = logging.StreamHandler() -# ch.setLevel(logging.DEBUG) -# # create formatter and add it to the handlers -# formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s @ %(lineno)d') -# #formatter = logging.Formatter('%(message)s') -# ch.setFormatter(formatter) -# # add the handlers to the logger -# logging.getLogger().addHandler(ch) -# else: -# logging.getLogger().setLevel(logging.DEBUG) -# ch = logging.StreamHandler() -# ch.setLevel(logging.DEBUG) -# # just like print -# formatter = logging.Formatter('%(message)s') -# ch.setFormatter(formatter) -# # add the handlers to the logger -# logging.getLogger().addHandler(ch) - - -# logger.info("This is DLMS Plugin running in standalone mode") -# logger.info("==============================================") - -# result = query(config) - -# if result is None: -# logger.info(f"No results from query, maybe a problem with the serial port '{config['serial_port']}' given ") -# logger.info("==============================================") -# elif len(result) > 0: -# logger.info("These are the results of the query") -# logger.info("==============================================") -# logger.info(result) -# logger.info("==============================================") -# else: -# logger.info("The query did not get any results!") -# logger.info("Maybe the serial was occupied or there was an error") +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description='Query a smartmeter at a given port for DLMS output', + usage='use "%(prog)s --help" for more information', + formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument('port', help='specify the port to use for the smartmeter query, e.g. /dev/ttyUSB0 or /dev/dlms0') + parser.add_argument('-v', '--verbose', help='print verbose information', action='store_true') + parser.add_argument('-t', '--timeout', help='maximum time to wait for a message from the smartmeter', type=float, default=3.0) + parser.add_argument('-b', '--baudrate', help='initial baudrate to start the communication with the smartmeter', type=int, default=300) + parser.add_argument('-d', '--device', help='give a device address to include in the query', default='') + parser.add_argument('-q', '--querycode', help='define alternative query code\ndefault query code is ?\nsome smartmeters provide additional information when sending\nan alternative query code, e.g. 2 instead of ?', default='?') + parser.add_argument('-l', '--onlylisten', help='only listen to serial, no active query', action='store_true') + parser.add_argument('-f', '--baudrate_fix', help='keep baudrate speed fixed', action='store_false') + parser.add_argument('-c', '--nochecksum', help='don\'t use a checksum', action='store_false') + + args = parser.parse_args() + + config = {} + + config['serial_port'] = args.port + config['timeout'] = args.timeout + config['dlms'] = {} + config['dlms']['querycode'] = args.querycode + config['dlms']['baudrate_min'] = args.baudrate + config['dlms']['baudrate_fix'] = args.baudrate_fix + config['dlms']['onlylisten'] = args.onlylisten + config['dlms']['use_checksum'] = args.nochecksum + config['dlms']['device'] = args.device + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + # create formatter and add it to the handlers + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s @ %(lineno)d') + # formatter = logging.Formatter('%(message)s') + ch.setFormatter(formatter) + # add the handlers to the logger + logging.getLogger().addHandler(ch) + else: + logging.getLogger().setLevel(logging.DEBUG) + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + # just like print + formatter = logging.Formatter('%(message)s') + ch.setFormatter(formatter) + # add the handlers to the logger + logging.getLogger().addHandler(ch) + + logger.info("This is Smartmeter Plugin, DLMS module, running in standalone mode") + logger.info("==================================================================") + + result = query(config) + + if result is None: + logger.info(f"No results from query, maybe a problem with the serial port '{config['serial_port']}' given.") + elif len(result) > 0: + logger.info("These are the results of the query:") + logger.info(result) + else: + logger.info("The query did not get any results. Maybe the serial port was occupied or there was an error.") diff --git a/smartmeter/get_manufacturer_ids.py b/smartmeter/get_manufacturer_ids.py index 66583f69f..f5a37b04c 100755 --- a/smartmeter/get_manufacturer_ids.py +++ b/smartmeter/get_manufacturer_ids.py @@ -43,7 +43,11 @@ from ruamel.yaml import YAML from io import BytesIO -install_openpyxl = "python3 -m pip install --user openpyxl" + +try: + import openpyxl +except ImportError: + sys.exit("Package 'openpyxl' was not found.") if __name__ == '__main__': logger = logging.getLogger(__name__) @@ -52,13 +56,8 @@ logger = logging.getLogger() logger.debug(f"init plugin component {__name__}") -try: - import openpyxl -except: - sys.exit(f"Package 'openpyxl' was not found. You might install with {install_openpyxl}") - -def get_manufacturer( from_url, to_yaml, verbose = False ): +def get_manufacturer(from_url: str, exportfile: str, verbose: bool = False) -> dict: """ Read XLSX from given url and write a yaml containing id and manufacturer """ @@ -66,9 +65,9 @@ def get_manufacturer( from_url, to_yaml, verbose = False ): r = {} y = YAML() - logger.debug(f"Read manufacturer IDs from URL: '{url}'") + logger.debug(f"Read manufacturer IDs from URL: '{from_url}'") logger.debug(f"Using openpyxl version '{openpyxl.__version__}'") - + headers = {'User-agent': 'Mozilla/5.0'} try: @@ -79,18 +78,17 @@ def get_manufacturer( from_url, to_yaml, verbose = False ): try: wb = openpyxl.load_workbook(filename=BytesIO(reque.content), data_only=True) - #wb = openpyxl.load_workbook(xlfilename, data_only=True) logger.debug('sheetnames {}'.format(wb.sheetnames)) - + sheet = wb.active logger.debug(f"sheet {sheet}") logger.debug(f"rows [{sheet.min_row} .. {sheet.max_row}]") logger.debug(f"columns [{sheet.min_column} .. {sheet.max_column}]") - + if sheet.min_row+1 <= sheet.max_row and sheet.min_column == 1 and sheet.max_column == 4: # Get data from rows """ - for row in range(sheet.min_row+1,sheet.max_row): + for row in range(sheet.min_row + 1,sheet.max_row): id = str(sheet.cell(row, 1).value).strip() if len(id) == 3: # there are entries like > 'ITRON ...' < that need special cleaning: @@ -102,19 +100,20 @@ def get_manufacturer( from_url, to_yaml, verbose = False ): else: logger.debug(f">id< is '{id}' has more than 3 characters and will not be considered") with open(exportfile, 'w') as f: - y.dump( r, f ) + y.dump(r, f) logger.debug(f"{len(r)} distinct manufacturers were found and written to {exportfile}") - + except Exception as e: logger.debug(f"Error {e} occurred") return r + if __name__ == '__main__': verbose = True - logging.getLogger().setLevel( logging.DEBUG ) + logging.getLogger().setLevel(logging.DEBUG) ch = logging.StreamHandler() ch.setLevel(logging.DEBUG) # create formatter and add it to the handlers @@ -129,5 +128,4 @@ def get_manufacturer( from_url, to_yaml, verbose = False ): exportfile = 'manufacturer.yaml' url = 'https://www.dlms.com/srv/lib/Export_Flagids.php' - get_manufacturer( url, exportfile, verbose) - + get_manufacturer(url, exportfile, verbose) From c42bc35678e741a0b1e5c6f306e55f470a2da832 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:36:20 +0100 Subject: [PATCH 03/34] smartmeter: disable DLMS testing mode --- smartmeter/dlms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py index 8e8afaa67..e55b524fd 100755 --- a/smartmeter/dlms.py +++ b/smartmeter/dlms.py @@ -81,7 +81,7 @@ # internal testing # TESTING = False -TESTING = True +# TESTING = True if TESTING: from .dlms_test import RESULT From a32f51c761c8212b40fb19ce08b90a34e092aaf1 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:04:54 +0100 Subject: [PATCH 04/34] smartmeter: fix type hinting for Python 3.9, minor fixes, add requirements --- smartmeter/__init__.py | 10 +++++++--- smartmeter/dlms.py | 1 + smartmeter/dlms_test.py | 2 +- smartmeter/plugin.yaml | 2 ++ smartmeter/requirements.txt | 1 + 5 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 smartmeter/requirements.txt diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index 6c5c32cdb..974d6d8f3 100644 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -44,6 +44,7 @@ from lib.item.item import Item from lib.shtime import Shtime from collections.abc import Callable +from typing import Union from . import dlms from . import sml @@ -89,6 +90,7 @@ def __init__(self, sh): # load parameters from config self._protocol = None + self._proto_detect = False self.load_parameters() # quit if errors on parameter read @@ -143,7 +145,7 @@ def load_parameters(self): # get mode (SML/DLMS) if set by user # if not set, try to get at runtime - self._protocol = self.get_parameter_value('protocol') + self._protocol = self.get_parameter_value('protocol').upper() # DLMS only self._config['dlms'] = {} @@ -203,12 +205,14 @@ def run(self): # TODO: call DLMS/SML discovery routines to find protocol if sml.discover(self._config): self._protocol = 'SML' + self._proto_detect = True elif dlms.discover(self._config): self._protocol = 'DLMS' + self._proto_detect = True self.alive = True if self._protocol: - self.logger.info(f'set/detected protocol {self._protocol}') + self.logger.info(f'{"detected" if self._proto_detect else "set"} protocol {self._protocol}') else: self.logger.error('unable to auto-detect device protocol (SML/DLMS). Try manual disconvery via standalone mode or Web Interface.') # skip cycle / crontab scheduler if no protocol set (only manual control from web interface) @@ -235,7 +239,7 @@ def stop(self): except Exception: pass - def parse_item(self, item: Item) -> Callable | None: + def parse_item(self, item: Item) -> Union[Callable, None]: """ Default plugin parse_item method. Is called when the plugin is initialized. diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py index e55b524fd..41e3e9f92 100755 --- a/smartmeter/dlms.py +++ b/smartmeter/dlms.py @@ -301,6 +301,7 @@ def query(config) -> dict: logger.error(f"Serial port '{serial_port}' could not be opened") else: logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") + return {} except OSError: logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") return {} diff --git a/smartmeter/dlms_test.py b/smartmeter/dlms_test.py index 665c45b7b..4798db32b 100644 --- a/smartmeter/dlms_test.py +++ b/smartmeter/dlms_test.py @@ -278,4 +278,4 @@ 1-1:0.2.0(B31) 1-1:0.2.1(005) ! -""" \ No newline at end of file +""" diff --git a/smartmeter/plugin.yaml b/smartmeter/plugin.yaml index 9f267fc82..ad575bd33 100644 --- a/smartmeter/plugin.yaml +++ b/smartmeter/plugin.yaml @@ -23,6 +23,8 @@ parameters: valid_list: - DLMS - SML + - dlms + - sml - '' default: '' description: diff --git a/smartmeter/requirements.txt b/smartmeter/requirements.txt new file mode 100644 index 000000000..f6c1a1f57 --- /dev/null +++ b/smartmeter/requirements.txt @@ -0,0 +1 @@ +pyserial From 99a8600e5e24406b099ba83803e34b0173408b63 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:11:41 +0100 Subject: [PATCH 05/34] smartmeter: add DLMS autodiscovery (untested) --- smartmeter/dlms.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py index 41e3e9f92..043a10f69 100755 --- a/smartmeter/dlms.py +++ b/smartmeter/dlms.py @@ -99,12 +99,6 @@ pass -def discover(config: dict) -> bool: - """ try to autodiscover SML protocol """ - # TODO: write this... - return True - - def format_time(timedelta: float) -> str: """ returns a pretty formatted string according to the size of the timedelta @@ -547,8 +541,6 @@ def query(config) -> dict: rdict = {} # {'readout': result} -# TODO : adjust - _, obis = split_header(result) try: @@ -595,6 +587,24 @@ def query(config) -> dict: return rdict +def discover(config: dict) -> bool: + """ try to autodiscover DLMS protocol """ + + # as of now, this simply tries to query the meter + # called from within the plugin, the parameters are either manually set by + # the user, or preset by the plugin.yaml defaults. + # If really necessary, the query could be called multiple times with + # reduced baud rates or changed parameters, but there would need to be + # the need for this. + # For now, let's see how well this works... + result = query(config) + + # result should have one key 'readout' with the full answer and a separate + # key for every read OBIS code. If no OBIS codes are read/converted, we can + # not be sure this is really DLMS, so we check for at least one OBIS code. + return len(result) > 1 + + if __name__ == '__main__': import argparse @@ -650,10 +660,17 @@ def query(config) -> dict: result = query(config) - if result is None: + if not result: logger.info(f"No results from query, maybe a problem with the serial port '{config['serial_port']}' given.") - elif len(result) > 0: - logger.info("These are the results of the query:") + elif len(result) > 1: + logger.info("These are the processed results of the query:") + try: + del result['readout'] + except KeyError: + pass + logger.info(result) + elif len(result) == 1: + logger.info("The results of the query could not be processed; raw result is:") logger.info(result) else: logger.info("The query did not get any results. Maybe the serial port was occupied or there was an error.") From 20b4f1b5675cc9c23f287b7b35009a03a6531aa6 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Tue, 3 Dec 2024 18:23:53 +0100 Subject: [PATCH 06/34] smartmeter: implement sml protocol, fix testing for both protocols --- smartmeter/__init__.py | 17 +- smartmeter/dlms.py | 383 ++++++------ smartmeter/dlms_test.py | 243 +------- smartmeter/plugin.yaml | 111 ++-- smartmeter/sml.py | 1307 +++++++++++++-------------------------- smartmeter/sml_test.py | 363 +++++++++++ 6 files changed, 1079 insertions(+), 1345 deletions(-) create mode 100644 smartmeter/sml_test.py diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index 974d6d8f3..8f6d77eda 100644 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -159,16 +159,17 @@ def load_parameters(self): # self._config['dlms']['no_waiting'] = self.get_parameter_value('no_waiting') # SML only + # disabled parameters are for old frame parser self._config['sml'] = {} - self._config['sml']['device'] = self.get_parameter_value('device_type') + # self._config['sml']['device'] = self.get_parameter_value('device_type') self._config['sml']['buffersize'] = self.get_parameter_value('buffersize') # 1024 - self._config['sml']['date_offset'] = self.get_parameter_value('date_offset') # 0 - self._config['sml']['poly'] = self.get_parameter_value('poly') # 0x1021 - self._config['sml']['reflect_in'] = self.get_parameter_value('reflect_in') # True - self._config['sml']['xor_in'] = self.get_parameter_value('xor_in') # 0xffff - self._config['sml']['reflect_out'] = self.get_parameter_value('reflect_out') # True - self._config['sml']['xor_out'] = self.get_parameter_value('xor_out') # 0xffff - self._config['sml']['swap_crc_bytes'] = self.get_parameter_value('swap_crc_bytes') # False + # self._config['sml']['date_offset'] = self.get_parameter_value('date_offset') # 0 + # self._config['sml']['poly'] = self.get_parameter_value('poly') # 0x1021 + # self._config['sml']['reflect_in'] = self.get_parameter_value('reflect_in') # True + # self._config['sml']['xor_in'] = self.get_parameter_value('xor_in') # 0xffff + # self._config['sml']['reflect_out'] = self.get_parameter_value('reflect_out') # True + # self._config['sml']['xor_out'] = self.get_parameter_value('xor_out') # 0xffff + # self._config['sml']['swap_crc_bytes'] = self.get_parameter_value('swap_crc_bytes') # False # # general plugin parameters diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py index 043a10f69..258b8671d 100755 --- a/smartmeter/dlms.py +++ b/smartmeter/dlms.py @@ -6,7 +6,7 @@ # Copyright 2024 - Sebastian Helms morg @ knx-user-forum.de ######################################################################### # -# DLMS plugin for SmartHomeNG +# DLMS module for SmartMeter plugin for SmartHomeNG # # This file is part of SmartHomeNG.py. # Visit: https://github.com/smarthomeNG/ @@ -34,6 +34,7 @@ __docformat__ = 'reStructuredText' import logging +import threading import time import serial @@ -84,7 +85,10 @@ # TESTING = True if TESTING: - from .dlms_test import RESULT + if __name__ == '__main__': + from dlms_test import RESULT + else: + from .dlms_test import RESULT logger.error('DLMS testing mode enabled, no serial communication, no real results!') else: RESULT = '' @@ -239,6 +243,7 @@ def query(config) -> dict: starttime = time.time() runtime = starttime result = None + lock = threading.Lock() try: serial_port = config['serial_port'] @@ -275,207 +280,219 @@ def query(config) -> dict: # ta < 1 500 ms wait_before_acknowledge = 0.4 # wait for 400 ms before sending the request to change baudrate wait_after_acknowledge = 0.4 # wait for 400 ms after sending acknowledge - dlms_serial = None - try: - dlms_serial = serial.Serial(serial_port, - initial_baudrate, - bytesize=serial.SEVENBITS, - parity=serial.PARITY_EVEN, - stopbits=serial.STOPBITS_ONE, - timeout=timeout) - if not serial_port == dlms_serial.name: - logger.debug(f"Asked for {serial_port} as serial port, but really using now {dlms_serial.name}") - - except FileNotFoundError: - logger.error(f"Serial port '{serial_port}' does not exist, please check your port") - return {} - except serial.SerialException: - if dlms_serial is None: - logger.error(f"Serial port '{serial_port}' could not be opened") - else: - logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") - return {} - except OSError: - logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") - return {} - except Exception as e: - logger.error(f"unforeseen error occurred: '{e}'") - return {} - - if dlms_serial is None: - # this should not happen... - logger.error("unforeseen error occurred, serial object was not initialized.") - return {} - if not dlms_serial.is_open: - logger.error(f"serial port '{serial_port}' could not be opened with given parameters, maybe wrong baudrate?") + locked = lock.acquire(blocking=False) + if not locked: + logger.error('could not get lock for serial access. Is another scheduled/manual action still active?') return {} - logger.debug(f"time to open serial port {serial_port}: {format_time(time.time() - runtime)}") - runtime = time.time() + try: # lock release + if not TESTING: + try: # open serial + dlms_serial = serial.Serial(serial_port, + initial_baudrate, + bytesize=serial.SEVENBITS, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + timeout=timeout) + if not serial_port == dlms_serial.name: + logger.debug(f"Asked for {serial_port} as serial port, but really using now {dlms_serial.name}") + + except FileNotFoundError: + logger.error(f"Serial port '{serial_port}' does not exist, please check your port") + return {} + except serial.SerialException: + if dlms_serial is None: + logger.error(f"Serial port '{serial_port}' could not be opened") + else: + logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") + return {} + except OSError: + logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") + return {} + except Exception as e: + logger.error(f"unforeseen error occurred: '{e}'") + return {} - acknowledge = b'' # preset empty answer + if dlms_serial is None: + # this should not happen... + logger.error("unforeseen error occurred, serial object was not initialized.") + return {} - if not only_listen: - # TODO: check/implement later - response = b'' + if not dlms_serial.is_open: + logger.error(f"serial port '{serial_port}' could not be opened with given parameters, maybe wrong baudrate?") + return {} - # start a dialog with smartmeter - try: - # TODO: is this needed? when? - # logger.debug(f"Reset input buffer from serial port '{serial_port}'") - # dlms_serial.reset_input_buffer() # replaced dlms_serial.flushInput() - logger.debug(f"writing request message {request_message} to serial port '{serial_port}'") - dlms_serial.write(request_message) - # TODO: same as above - # logger.debug(f"Flushing buffer from serial port '{serial_port}'") - # dlms_serial.flush() # replaced dlms_serial.drainOutput() - except Exception as e: - logger.warning(f"error on serial write: {e}") - return {} - - logger.debug(f"time to send first request to smartmeter: {format_time(time.time() - runtime)}") - - # now get first response - response = read_data_block_from_serial(dlms_serial) - if not response: - logger.debug("no response received upon first request") - return {} - - logger.debug(f"time to receive an answer: {format_time(time.time() - runtime)}") + logger.debug(f"time to open serial port {serial_port}: {format_time(time.time() - runtime)}") runtime = time.time() - # We need to examine the read response here for an echo of the _Request_Message - # some meters answer with an echo of the request Message - if response == request_message: - logger.debug("request message was echoed, need to read the identification message") - # now read the capabilities and type/brand line from Smartmeter - # e.g. b'/LGZ5\\2ZMD3104407.B32\r\n' - response = read_data_block_from_serial(dlms_serial) - else: - logger.debug("request message was not equal to response, treating as identification message") + acknowledge = b'' # preset empty answer - logger.debug(f"time to get first identification message from smartmeter: {format_time(time.time() - runtime)}") - runtime = time.time() + if not only_listen: + # TODO: check/implement later + response = b'' - identification_message = response - logger.debug(f"identification message is {identification_message}") - - # need at least 7 bytes: - # 1 byte "/" - # 3 bytes short Identification - # 1 byte speed indication - # 2 bytes CR LF - if len(identification_message) < 7: - logger.warning(f"malformed identification message: '{identification_message}', abort query") - return {} - - if (identification_message[0] != start_char): - logger.warning(f"identification message '{identification_message}' does not start with '/', abort query") - return {} - - manid = str(identification_message[1:4], 'utf-8') - manname = manufacturer_ids.get(manid, 'unknown') - logger.debug(f"manufacturer for {manid} is {manname} ({len(manufacturer_ids)} manufacturers known)") - - # Different smartmeters allow for different protocol modes. - # The protocol mode decides whether the communication is fixed to a certain baudrate or might be speed up. - # Some meters do initiate a protocol by themselves with a fixed speed of 2400 baud e.g. Mode D - # However some meters specify a speed of 9600 Baud although they use protocol mode D (readonly) - # - # protocol_mode = 'A' - # - # The communication of the plugin always stays at the same speed, - # Protocol indicator can be anything except for A-I, 0-9, /, ? - # - baudrates = { - # mode A - '': (300, 'A'), - # mode B - 'A': (600, 'B'), - 'B': (1200, 'B'), - 'C': (2400, 'B'), - 'D': (4800, 'B'), - 'E': (9600, 'B'), - 'F': (19200, 'B'), - # mode C & E - '0': (300, 'C'), - '1': (600, 'C'), - '2': (1200, 'C'), - '3': (2400, 'C'), - '4': (4800, 'C'), - '5': (9600, 'C'), - '6': (19200, 'C'), - } + # start a dialog with smartmeter + try: + # TODO: is this needed? when? + # logger.debug(f"Reset input buffer from serial port '{serial_port}'") + # dlms_serial.reset_input_buffer() # replaced dlms_serial.flushInput() + logger.debug(f"writing request message {request_message} to serial port '{serial_port}'") + dlms_serial.write(request_message) + # TODO: same as above + # logger.debug(f"Flushing buffer from serial port '{serial_port}'") + # dlms_serial.flush() # replaced dlms_serial.drainOutput() + except Exception as e: + logger.warning(f"error on serial write: {e}") + return {} - baudrate_id = chr(identification_message[4]) - if baudrate_id not in baudrates: - baudrate_id = '' - new_baudrate, protocol_mode = baudrates[baudrate_id] + logger.debug(f"time to send first request to smartmeter: {format_time(time.time() - runtime)}") - logger.debug(f"baudrate id is '{baudrate_id}' thus protocol mode is {protocol_mode} and suggested Baudrate is {new_baudrate} Bd") + # now get first response + response = read_data_block_from_serial(dlms_serial) + if not response: + logger.debug("no response received upon first request") + return {} - if chr(identification_message[5]) == '\\': - if chr(identification_message[6]) == '2': - logger.debug("HDLC protocol could be used if it was implemented") + logger.debug(f"time to receive an answer: {format_time(time.time() - runtime)}") + runtime = time.time() + + # We need to examine the read response here for an echo of the _Request_Message + # some meters answer with an echo of the request Message + if response == request_message: + logger.debug("request message was echoed, need to read the identification message") + # now read the capabilities and type/brand line from Smartmeter + # e.g. b'/LGZ5\\2ZMD3104407.B32\r\n' + response = read_data_block_from_serial(dlms_serial) else: - logger.debug(f"another protocol could probably be used if it was implemented, id is {identification_message[6]}") - - # for protocol C or E we now send an acknowledge and include the new baudrate parameter - # maybe todo - # we could implement here a baudrate that is fixed to somewhat lower speed if we need to - # read out a smartmeter with broken communication - action = b'0' # Data readout, possible are also b'1' for programming mode or some manufacturer specific - acknowledge = b'\x060' + baudrate_id.encode() + action + b'\r\n' - - if protocol_mode == 'C': - # the speed change in communication is initiated from the reading device - time.sleep(wait_before_acknowledge) - logger.debug(f"using protocol mode C, send acknowledge {acknowledge} and tell smartmeter to switch to {new_baudrate} baud") - try: - dlms_serial.write(acknowledge) - except Exception as e: - logger.warning(f"error on sending baudrate change: {e}") + logger.debug("request message was not equal to response, treating as identification message") + + logger.debug(f"time to get first identification message from smartmeter: {format_time(time.time() - runtime)}") + runtime = time.time() + + identification_message = response + logger.debug(f"identification message is {identification_message}") + + # need at least 7 bytes: + # 1 byte "/" + # 3 bytes short Identification + # 1 byte speed indication + # 2 bytes CR LF + if len(identification_message) < 7: + logger.warning(f"malformed identification message: '{identification_message}', abort query") return {} - time.sleep(wait_after_acknowledge) - # dlms_serial.flush() - # dlms_serial.reset_input_buffer() - if (new_baudrate != initial_baudrate): - # change request to set higher baudrate - dlms_serial.baudrate = new_baudrate - - elif protocol_mode == 'B': - # the speed change in communication is initiated from the smartmeter device - time.sleep(wait_before_acknowledge) - logger.debug(f"using protocol mode B, smartmeter and reader will switch to {new_baudrate} baud") - time.sleep(wait_after_acknowledge) - # dlms_serial.flush() - # dlms_serial.reset_input_buffer() - if (new_baudrate != initial_baudrate): - # change request to set higher baudrate - dlms_serial.baudrate = new_baudrate - else: - logger.debug(f"no change of readout baudrate, smartmeter and reader will stay at {new_baudrate} baud") - # now read the huge data block with all the OBIS codes - logger.debug("Reading OBIS data from smartmeter") - response = read_data_block_from_serial(dlms_serial, b'') - else: - # only listen mode, starts with / and last char is ! - # data will be in between those two - response = read_data_block_from_serial(dlms_serial, b'!', b'/') + if (identification_message[0] != start_char): + logger.warning(f"identification message '{identification_message}' does not start with '/', abort query") + return {} + + manid = str(identification_message[1:4], 'utf-8') + manname = manufacturer_ids.get(manid, 'unknown') + logger.debug(f"manufacturer for {manid} is {manname} ({len(manufacturer_ids)} manufacturers known)") + + # Different smartmeters allow for different protocol modes. + # The protocol mode decides whether the communication is fixed to a certain baudrate or might be speed up. + # Some meters do initiate a protocol by themselves with a fixed speed of 2400 baud e.g. Mode D + # However some meters specify a speed of 9600 Baud although they use protocol mode D (readonly) + # + # protocol_mode = 'A' + # + # The communication of the plugin always stays at the same speed, + # Protocol indicator can be anything except for A-I, 0-9, /, ? + # + baudrates = { + # mode A + '': (300, 'A'), + # mode B + 'A': (600, 'B'), + 'B': (1200, 'B'), + 'C': (2400, 'B'), + 'D': (4800, 'B'), + 'E': (9600, 'B'), + 'F': (19200, 'B'), + # mode C & E + '0': (300, 'C'), + '1': (600, 'C'), + '2': (1200, 'C'), + '3': (2400, 'C'), + '4': (4800, 'C'), + '5': (9600, 'C'), + '6': (19200, 'C'), + } + + baudrate_id = chr(identification_message[4]) + if baudrate_id not in baudrates: + baudrate_id = '' + new_baudrate, protocol_mode = baudrates[baudrate_id] + + logger.debug(f"baudrate id is '{baudrate_id}' thus protocol mode is {protocol_mode} and suggested Baudrate is {new_baudrate} Bd") + + if chr(identification_message[5]) == '\\': + if chr(identification_message[6]) == '2': + logger.debug("HDLC protocol could be used if it was implemented") + else: + logger.debug(f"another protocol could probably be used if it was implemented, id is {identification_message[6]}") + + # for protocol C or E we now send an acknowledge and include the new baudrate parameter + # maybe todo + # we could implement here a baudrate that is fixed to somewhat lower speed if we need to + # read out a smartmeter with broken communication + action = b'0' # Data readout, possible are also b'1' for programming mode or some manufacturer specific + acknowledge = b'\x060' + baudrate_id.encode() + action + b'\r\n' + + if protocol_mode == 'C': + # the speed change in communication is initiated from the reading device + time.sleep(wait_before_acknowledge) + logger.debug(f"using protocol mode C, send acknowledge {acknowledge} and tell smartmeter to switch to {new_baudrate} baud") + try: + dlms_serial.write(acknowledge) + except Exception as e: + logger.warning(f"error on sending baudrate change: {e}") + return {} + time.sleep(wait_after_acknowledge) + # dlms_serial.flush() + # dlms_serial.reset_input_buffer() + if (new_baudrate != initial_baudrate): + # change request to set higher baudrate + dlms_serial.baudrate = new_baudrate + + elif protocol_mode == 'B': + # the speed change in communication is initiated from the smartmeter device + time.sleep(wait_before_acknowledge) + logger.debug(f"using protocol mode B, smartmeter and reader will switch to {new_baudrate} baud") + time.sleep(wait_after_acknowledge) + # dlms_serial.flush() + # dlms_serial.reset_input_buffer() + if (new_baudrate != initial_baudrate): + # change request to set higher baudrate + dlms_serial.baudrate = new_baudrate + else: + logger.debug(f"no change of readout baudrate, smartmeter and reader will stay at {new_baudrate} baud") - identification_message = str(response, 'utf-8').splitlines()[0] + # now read the huge data block with all the OBIS codes + logger.debug("Reading OBIS data from smartmeter") + response = read_data_block_from_serial(dlms_serial, b'') + else: + # only listen mode, starts with / and last char is ! + # data will be in between those two + response = read_data_block_from_serial(dlms_serial, b'!', b'/') - manid = identification_message[1:4] - manname = manufacturer_ids.get(manid, 'unknown') - logger.debug(f"manufacturer for {manid} is {manname} (out of {len(manufacturer_ids)} given manufacturers)") + identification_message = str(response, 'utf-8').splitlines()[0] - try: - dlms_serial.close() + manid = identification_message[1:4] + manname = manufacturer_ids.get(manid, 'unknown') + logger.debug(f"manufacturer for {manid} is {manname} (out of {len(manufacturer_ids)} given manufacturers)") + + try: + dlms_serial.close() + except Exception: + pass except Exception: - pass + # passthrough, this is only for releasing the lock + raise + finally: + lock.release() logger.debug(f"time for reading OBIS data: {format_time(time.time() - runtime)}") runtime = time.time() @@ -541,7 +558,7 @@ def query(config) -> dict: rdict = {} # {'readout': result} - _, obis = split_header(result) + obis = split_header(result) try: for line in obis: diff --git a/smartmeter/dlms_test.py b/smartmeter/dlms_test.py index 4798db32b..03e8d89ae 100644 --- a/smartmeter/dlms_test.py +++ b/smartmeter/dlms_test.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 -RESULT = """1-1:F.F(00000000) +RESULT = """/ +1-1:F.F(00000000) 1-1:0.0.0(97734234) 1-1:0.0.1(97734234) 1-1:0.9.1(145051) @@ -13,261 +14,21 @@ 1-1:2.2.1(0123.46*kW) 1-1:2.2.2(0123.46*kW) 1-1:1.6.1(07.98*kW)(2411061415) -1-1:1.6.1*30(07.60)(2410101115) -1-1:1.6.1*29(06.10)(2409160830) -1-1:1.6.1*28(05.35)(2408081545) -1-1:1.6.1*27(04.11)(2407181515) -1-1:1.6.1*26(05.26)(2406041400) -1-1:1.6.1*25(06.80)(2405311000) -1-1:1.6.1*24(04.50)(2404110945) -1-1:1.6.1*23(11.15)(2403051545) -1-1:1.6.1*22(09.15)(2402211445) -1-1:1.6.1*21(08.97)(2401191030) -1-1:1.6.1*20(24.08)(2312121045) -1-1:1.6.1*19(18.56)(2311060845) -1-1:1.6.1*18(23.05)(2310241530) -1-1:1.6.1*17(20.60)(2309111330) -1-1:1.6.1*16(21.48)(2308251330) 1-1:1.6.2(07.98*kW)(2411061415) -1-1:1.6.2*30(07.60)(2410101115) -1-1:1.6.2*29(06.10)(2409160830) -1-1:1.6.2*28(05.35)(2408081545) -1-1:1.6.2*27(04.11)(2407181515) -1-1:1.6.2*26(05.26)(2406041400) -1-1:1.6.2*25(06.80)(2405311000) -1-1:1.6.2*24(04.50)(2404110945) -1-1:1.6.2*23(11.15)(2403051545) -1-1:1.6.2*22(09.15)(2402211445) -1-1:1.6.2*21(08.97)(2401191030) -1-1:1.6.2*20(24.08)(2312121045) -1-1:1.6.2*19(18.56)(2311060845) -1-1:1.6.2*18(23.05)(2310241530) -1-1:1.6.2*17(20.60)(2309111330) -1-1:1.6.2*16(21.48)(2308251330) 1-1:2.6.1(01.84*kW)(2411021345) -1-1:2.6.1*30(03.32)(2410051445) -1-1:2.6.1*29(04.35)(2409011430) -1-1:2.6.1*28(05.62)(2408311415) -1-1:2.6.1*27(06.31)(2407141445) -1-1:2.6.1*26(06.43)(2406151330) -1-1:2.6.1*25(06.15)(2405251315) -1-1:2.6.1*24(05.84)(2404211345) -1-1:2.6.1*23(04.99)(2403251400) -1-1:2.6.1*22(02.58)(2402171330) -1-1:2.6.1*21(01.35)(2401271345) -1-1:2.6.1*20(00.54)(2312251200) -1-1:2.6.1*19(00.84)(2311121315) -1-1:2.6.1*18(03.24)(2310141415) -1-1:2.6.1*17(04.43)(2309031430) -1-1:2.6.1*16(05.76)(2308031445) 1-1:2.6.2(01.84*kW)(2411021345) -1-1:2.6.2*30(03.32)(2410051445) -1-1:2.6.2*29(04.35)(2409011430) -1-1:2.6.2*28(05.62)(2408311415) -1-1:2.6.2*27(06.31)(2407141445) -1-1:2.6.2*26(06.43)(2406151330) -1-1:2.6.2*25(06.15)(2405251315) -1-1:2.6.2*24(05.84)(2404211345) -1-1:2.6.2*23(04.99)(2403251400) -1-1:2.6.2*22(02.58)(2402171330) -1-1:2.6.2*21(01.35)(2401271345) -1-1:2.6.2*20(00.54)(2312251200) -1-1:2.6.2*19(00.84)(2311121315) -1-1:2.6.2*18(03.24)(2310141415) -1-1:2.6.2*17(04.43)(2309031430) -1-1:2.6.2*16(05.76)(2308031445) 1-1:1.8.0(00043802*kWh) -1-1:1.8.0*30(00042781) -1-1:1.8.0*29(00041912) -1-1:1.8.0*28(00041227) -1-1:1.8.0*27(00040639) -1-1:1.8.0*26(00040118) -1-1:1.8.0*25(00039674) -1-1:1.8.0*24(00039139) -1-1:1.8.0*23(00038600) -1-1:1.8.0*22(00037417) -1-1:1.8.0*21(00035961) -1-1:1.8.0*20(00034776) -1-1:1.8.0*19(00032557) -1-1:1.8.0*18(00030476) -1-1:1.8.0*17(00028420) -1-1:1.8.0*16(00026978) 1-1:2.8.0(00005983*kWh) -1-1:2.8.0*30(00005979) -1-1:2.8.0*29(00005931) -1-1:2.8.0*28(00005717) -1-1:2.8.0*27(00005284) -1-1:2.8.0*26(00004705) -1-1:2.8.0*25(00004135) -1-1:2.8.0*24(00003546) -1-1:2.8.0*23(00003198) -1-1:2.8.0*22(00003075) -1-1:2.8.0*21(00003063) -1-1:2.8.0*20(00003060) -1-1:2.8.0*19(00003059) -1-1:2.8.0*18(00003056) -1-1:2.8.0*17(00003027) -1-1:2.8.0*16(00002869) 1-1:3.8.0(00021203*kvarh) -1-1:3.8.0*30(00021129) -1-1:3.8.0*29(00021035) -1-1:3.8.0*28(00020920) -1-1:3.8.0*27(00020788) -1-1:3.8.0*26(00020697) -1-1:3.8.0*25(00020622) -1-1:3.8.0*24(00020429) -1-1:3.8.0*23(00020403) -1-1:3.8.0*22(00020116) -1-1:3.8.0*21(00019929) -1-1:3.8.0*20(00019739) -1-1:3.8.0*19(00018838) -1-1:3.8.0*18(00017921) -1-1:3.8.0*17(00016923) -1-1:3.8.0*16(00016094) 1-1:4.8.0(00006222*kvarh) -1-1:4.8.0*30(00005926) -1-1:4.8.0*29(00005638) -1-1:4.8.0*28(00005404) -1-1:4.8.0*27(00005179) -1-1:4.8.0*26(00004943) -1-1:4.8.0*25(00004722) -1-1:4.8.0*24(00004526) -1-1:4.8.0*23(00004306) -1-1:4.8.0*22(00004054) -1-1:4.8.0*21(00003799) -1-1:4.8.0*20(00003550) -1-1:4.8.0*19(00003344) -1-1:4.8.0*18(00003156) -1-1:4.8.0*17(00002957) -1-1:4.8.0*16(00002771) 1-1:1.8.1(00035256*kWh) -1-1:1.8.1*30(00034502) -1-1:1.8.1*29(00033921) -1-1:1.8.1*28(00033497) -1-1:1.8.1*27(00033174) -1-1:1.8.1*26(00032943) -1-1:1.8.1*25(00032746) -1-1:1.8.1*24(00032461) -1-1:1.8.1*23(00032177) -1-1:1.8.1*22(00031377) -1-1:1.8.1*21(00030337) -1-1:1.8.1*20(00029431) -1-1:1.8.1*19(00027499) -1-1:1.8.1*18(00025699) -1-1:1.8.1*17(00023923) -1-1:1.8.1*16(00022750) 1-1:1.8.2(00008545*kWh) -1-1:1.8.2*30(00008279) -1-1:1.8.2*29(00007990) -1-1:1.8.2*28(00007730) -1-1:1.8.2*27(00007465) -1-1:1.8.2*26(00007174) -1-1:1.8.2*25(00006927) -1-1:1.8.2*24(00006678) -1-1:1.8.2*23(00006422) -1-1:1.8.2*22(00006039) -1-1:1.8.2*21(00005623) -1-1:1.8.2*20(00005344) -1-1:1.8.2*19(00005057) -1-1:1.8.2*18(00004777) -1-1:1.8.2*17(00004496) -1-1:1.8.2*16(00004227) 1-1:2.8.1(00005983*kWh) -1-1:2.8.1*30(00005979) -1-1:2.8.1*29(00005931) -1-1:2.8.1*28(00005717) -1-1:2.8.1*27(00005284) -1-1:2.8.1*26(00004705) -1-1:2.8.1*25(00004135) -1-1:2.8.1*24(00003546) -1-1:2.8.1*23(00003198) -1-1:2.8.1*22(00003075) -1-1:2.8.1*21(00003063) -1-1:2.8.1*20(00003060) -1-1:2.8.1*19(00003059) -1-1:2.8.1*18(00003056) -1-1:2.8.1*17(00003027) -1-1:2.8.1*16(00002869) 1-1:2.8.2(00000000*kWh) -1-1:2.8.2*30(00000000) -1-1:2.8.2*29(00000000) -1-1:2.8.2*28(00000000) -1-1:2.8.2*27(00000000) -1-1:2.8.2*26(00000000) -1-1:2.8.2*25(00000000) -1-1:2.8.2*24(00000000) -1-1:2.8.2*23(00000000) -1-1:2.8.2*22(00000000) -1-1:2.8.2*21(00000000) -1-1:2.8.2*20(00000000) -1-1:2.8.2*19(00000000) -1-1:2.8.2*18(00000000) -1-1:2.8.2*17(00000000) -1-1:2.8.2*16(00000000) 1-1:3.8.1(00021081*kvarh) -1-1:3.8.1*30(00021007) -1-1:3.8.1*29(00020913) -1-1:3.8.1*28(00020800) -1-1:3.8.1*27(00020679) -1-1:3.8.1*26(00020597) -1-1:3.8.1*25(00020523) -1-1:3.8.1*24(00020330) -1-1:3.8.1*23(00020304) -1-1:3.8.1*22(00020023) -1-1:3.8.1*21(00019837) -1-1:3.8.1*20(00019647) -1-1:3.8.1*19(00018746) -1-1:3.8.1*18(00017829) -1-1:3.8.1*17(00016835) -1-1:3.8.1*16(00016012) 1-1:3.8.2(00000122*kvarh) -1-1:3.8.2*30(00000122) -1-1:3.8.2*29(00000122) -1-1:3.8.2*28(00000119) -1-1:3.8.2*27(00000109) -1-1:3.8.2*26(00000099) -1-1:3.8.2*25(00000099) -1-1:3.8.2*24(00000099) -1-1:3.8.2*23(00000099) -1-1:3.8.2*22(00000092) -1-1:3.8.2*21(00000092) -1-1:3.8.2*20(00000092) -1-1:3.8.2*19(00000092) -1-1:3.8.2*18(00000092) -1-1:3.8.2*17(00000088) -1-1:3.8.2*16(00000081) 1-1:4.8.1(00003666*kvarh) -1-1:4.8.1*30(00003482) -1-1:4.8.1*29(00003302) -1-1:4.8.1*28(00003159) -1-1:4.8.1*27(00003025) -1-1:4.8.1*26(00002882) -1-1:4.8.1*25(00002746) -1-1:4.8.1*24(00002628) -1-1:4.8.1*23(00002497) -1-1:4.8.1*22(00002342) -1-1:4.8.1*21(00002182) -1-1:4.8.1*20(00002019) -1-1:4.8.1*19(00001898) -1-1:4.8.1*18(00001790) -1-1:4.8.1*17(00001678) -1-1:4.8.1*16(00001572) 1-1:4.8.2(00002555*kvarh) -1-1:4.8.2*30(00002444) -1-1:4.8.2*29(00002335) -1-1:4.8.2*28(00002245) -1-1:4.8.2*27(00002153) -1-1:4.8.2*26(00002060) -1-1:4.8.2*25(00001975) -1-1:4.8.2*24(00001897) -1-1:4.8.2*23(00001809) -1-1:4.8.2*22(00001712) -1-1:4.8.2*21(00001616) -1-1:4.8.2*20(00001530) -1-1:4.8.2*19(00001446) -1-1:4.8.2*18(00001365) -1-1:4.8.2*17(00001279) -1-1:4.8.2*16(00001198) 1-1:C.3.1(___-----) 1-1:C.3.2(__------) 1-1:C.3.3(__------) diff --git a/smartmeter/plugin.yaml b/smartmeter/plugin.yaml index ad575bd33..69fee8ca2 100644 --- a/smartmeter/plugin.yaml +++ b/smartmeter/plugin.yaml @@ -117,54 +117,55 @@ parameters: description: de: 'Port für die Kommunikation (nur SML)' en: 'Port for communication (SML only)' - device_type: - type: str - default: 'raw' - description: - de: 'Name des Gerätes (nur SML)' - en: 'Name of Smartmeter (SML only)' - date_offset: - type: int - default: 0 - description: - de: 'Unix timestamp der Smartmeter Inbetriebnahme (nur SML)' - en: 'Unix timestamp of Smartmeter start-up after installation (SML only)' - poly: - type: int - default: 0x1021 - description: - de: 'Polynom für die crc Berechnung (nur SML)' - en: 'Polynomial for crc calculation (SML only)' - reflect_in: - type: bool - default: true - description: - de: 'Umkehren der Bitreihenfolge für die Eingabe (nur SML)' - en: 'Reflect the octets in the input (SML only)' - xor_in: - type: int - default: 0xffff - description: - de: 'Initialer Wert für XOR Berechnung (nur SML)' - en: 'Initial value for XOR calculation (SML only)' - reflect_out: - type: bool - default: true - description: - de: 'Umkehren der Bitreihenfolge der Checksumme vor der Anwendung des XOR Wertes (nur SML)' - en: 'Reflect the octet of checksum before application of XOR value (SML only)' - xor_out: - type: int - default: 0xffff - description: - de: 'XOR Berechnung der CRC mit diesem Wert (nur SML)' - en: 'XOR final CRC value with this value (SML only)' - swap_crc_bytes: - type: bool - default: false - description: - de: 'Bytereihenfolge der berechneten Checksumme vor dem Vergleich mit der vorgegeben Checksumme tauschen (nur SML)' - en: 'Swap bytes of calculated checksum prior to comparison with given checksum (SML only)' + # the following parameters are for the old frame parser + # device_type: + # type: str + # default: 'raw' + # description: + # de: 'Name des Gerätes (nur SML)' + # en: 'Name of Smartmeter (SML only)' + # date_offset: + # type: int + # default: 0 + # description: + # de: 'Unix timestamp der Smartmeter Inbetriebnahme (nur SML)' + # en: 'Unix timestamp of Smartmeter start-up after installation (SML only)' + # poly: + # type: int + # default: 0x1021 + # description: + # de: 'Polynom für die crc Berechnung (nur SML)' + # en: 'Polynomial for crc calculation (SML only)' + # reflect_in: + # type: bool + # default: true + # description: + # de: 'Umkehren der Bitreihenfolge für die Eingabe (nur SML)' + # en: 'Reflect the octets in the input (SML only)' + # xor_in: + # type: int + # default: 0xffff + # description: + # de: 'Initialer Wert für XOR Berechnung (nur SML)' + # en: 'Initial value for XOR calculation (SML only)' + # reflect_out: + # type: bool + # default: true + # description: + # de: 'Umkehren der Bitreihenfolge der Checksumme vor der Anwendung des XOR Wertes (nur SML)' + # en: 'Reflect the octet of checksum before application of XOR value (SML only)' + # xor_out: + # type: int + # default: 0xffff + # description: + # de: 'XOR Berechnung der CRC mit diesem Wert (nur SML)' + # en: 'XOR final CRC value with this value (SML only)' + # swap_crc_bytes: + # type: bool + # default: false + # description: + # de: 'Bytereihenfolge der berechneten Checksumme vor dem Vergleich mit der vorgegeben Checksumme tauschen (nur SML)' + # en: 'Swap bytes of calculated checksum prior to comparison with given checksum (SML only)' item_attributes: # Definition of item attributes defined by this plugin @@ -187,15 +188,29 @@ item_attributes: valid_list: - value - unit + - scaler + - status + - valTime + - signature description: de: > Eigenschaft des gelesenen Wertes: * value: gelesener Wert (siehe obis_index) * unit: zurückgegebene Einheit des Wertes + * scaler: Multiplikationsfaktor + * status: tbd + * valTime: Zeitstempel des Wertes + * signature: tbc + Nicht alle Eigenschaften sind in jedem Datenpunkt vorhanden. en: > property of the read data: * value: read obis value (see obis_index) * unit: read unit of value + * scaler: multiplicative factor + * status: tbd + * valTime: timestamp of the value + * signature: tbd + Not all properties are present for all data points. obis_vtype: type: str default: '' diff --git a/smartmeter/sml.py b/smartmeter/sml.py index b1ec58014..556bffb0f 100644 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -2,11 +2,11 @@ # vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab ######################################################################### # Copyright 2013 - 2015 KNX-User-Forum e.V. http://knx-user-forum.de/ -# Copyright 2016 - 2022 Bernd Meiners Bernd.Meiners@mail.de +# Copyright 2022 Julian Scholle julian.scholle@googlemail.com # Copyright 2024 - Sebastian Helms morg @ knx-user-forum.de ######################################################################### # -# SML plugin for SmartHomeNG +# SML module for SmartMeter plugin for SmartHomeNG # # This file is part of SmartHomeNG.py. # Visit: https://github.com/smarthomeNG/ @@ -33,8 +33,47 @@ __revision__ = "0.1" __docformat__ = 'reStructuredText' +import errno import logging -from ruamel.yaml import YAML +import serial +import socket +import time +import traceback + +from smllib.reader import SmlStreamReader +from smllib import const as smlConst +from threading import Lock + +# needed for old frame parser +# from .crc import Crc + + +""" +This module implements the query of a smartmeter using the SML protocol. +The smartmeter needs to have an infrared interface and an IR-Adapter is needed for USB. + +Abbreviations +------------- +OBIS + OBject Identification System (see iec62056-61{ed1.0}en_obis_protocol.pdf) +""" + + +OBIS_NAMES = { + **smlConst.OBIS_NAMES, + '010000020000': 'Firmware Version, Firmware Prüfsumme CRC, Datum', + '0100010800ff': 'Bezug Zählerstand Total', + '0100010801ff': 'Bezug Zählerstand Tarif 1', + '0100010802ff': 'Bezug Zählerstand Tarif 2', + '0100011100ff': 'Total-Zählerstand', + '0100020800ff': 'Einspeisung Zählerstand Total', + '0100020801ff': 'Einspeisung Zählerstand Tarif 1', + '0100020802ff': 'Einspeisung Zählerstand Tarif 2', + '0100600100ff': 'Server-ID', + '010060320101': 'Hersteller-Identifikation', + '0100605a0201': 'Prüfsumme', +} + if __name__ == '__main__': logger = logging.getLogger(__name__) @@ -43,875 +82,413 @@ logger = logging.getLogger(__name__) logger.debug(f"init plugin component {__name__}") -import time -import serial -import re -from threading import Lock +# +# internal testing +# +TESTING = False +# TESTING = True -def discover(config: dict) -> bool: - """ try to autodiscover SML protocol """ - return False +if TESTING: + if __name__ == '__main__': + from sml_test import RESULT + else: + from .sml_test import RESULT + logger.error('SML testing mode enabled, no serial communication, no real results!') +else: + RESULT = '' -def query(config: dict) -> dict: - """ query smartmeter and return result """ - return {} +def to_hex(data: int | str | bytes | bytearray, space: bool = True) -> str: + """ + Returns the hex representation of the given data + """ + if isinstance(data, int): + return hex(data) + templ = "%02x" + if space: + templ += " " + return "".join(templ % b for b in data).rstrip() + + +def format_time(timedelta): + """ + returns a pretty formatted string according to the size of the timedelta + :param timediff: time delta given in seconds + :return: returns a string + """ + if timedelta > 1000.0: + return f"{timedelta:.2f} s" + elif timedelta > 1.0: + return f"{timedelta:.2f} s" + elif timedelta > 0.001: + return f"{timedelta*1000.0:.2f} ms" + elif timedelta > 0.000001: + return f"{timedelta*1000000.0:.2f} µs" + elif timedelta > 0.000000001: + return f"{timedelta * 1000000000.0:.2f} ns" + + +def _read(sock, length: int) -> bytes: + """ isolate the read method from the connection object """ + if isinstance(sock, serial.Serial): + return sock.read() + elif isinstance(sock, socket.socket): + return sock.recv(length) + else: + return b'' + + +def read(sock: serial.Serial | socket.socket, length: int = 0) -> bytes: + """ + This function reads some bytes from serial or network interface + it returns an array of bytes if a timeout occurs or a given end byte is encountered + and otherwise b'' if an error occurred + :returns the read data + """ + if TESTING: + return RESULT + + logger.debug("start to read data from serial/network device") + response = bytes() + while True: + try: + # on serial, length is ignored + data = _read(sock, length) + if data: + response += data + if len(response) >= length: + logger.debug('read end, length reached') + break + else: + if isinstance(sock, serial.Serial): + logger.debug('read end, end of data reached') + break + except socket.error as e: + if e.args[0] == errno.EAGAIN or e.args[0] == errno.EWOULDBLOCK: + logger.debug(f'read end, error: {e}') + break + else: + raise + except Exception as e: + logger.debug(f"error while reading from serial/network: {e}") + return b'' + + logger.debug(f"finished reading data from serial/network {len(response)} bytes") + return response # -#manufacturer_ids = {} -# -#exportfile = 'manufacturer.yaml' -#try: -# with open(exportfile, 'r') as infile: -# y = YAML(typ='safe') -# manufacturer_ids = y.load(infile) -#except: -# pass -#""" -#This module implements the query of a smartmeter using the SML protocol. -#The smartmeter needs to have an infrared interface and an IR-Adapter is needed for USB. -# -#The Character Format for protocol mode A - D is defined as 1 start bit, 7 data bits, 1 parity bit, 1 stop bit and even parity -#In protocol mode E it is defined as 1 start bit, 8 data bits, 1 stop bit is allowed, see Annex E of IEC62056-21 -#For this plugin the protocol mode E is neither implemented nor supported. -# -#Abbreviations -#------------- -#COSEM -# COmpanion Specification for Energy Metering -# -#OBIS -# OBject Identification System (see iec62056-61{ed1.0}en_obis_protocol.pdf) -# -#""" -# -#SOH = 0x01 # start of header -#STX = 0x02 # start of text -#ETX = 0x03 # end of text -#ACK = 0x06 # acknowledge -#CR = 0x0D # carriage return -#LF = 0x0A # linefeed -#BCC = 0x00 # Block check Character will contain the checksum immediately following the data packet -# -# -#def format_time( timedelta ): -# """ -# returns a pretty formatted string according to the size of the timedelta -# :param timediff: time delta given in seconds -# :return: returns a string -# """ -# if timedelta > 1000.0: -# return f"{timedelta:.2f} s" -# elif timedelta > 1.0: -# return f"{timedelta:.2f} s" -# elif timedelta > 0.001: -# return f"{timedelta*1000.0:.2f} ms" -# elif timedelta > 0.000001: -# return f"{timedelta*1000000.0:.2f} µs" -# elif timedelta > 0.000000001: -# return f"{timedelta * 1000000000.0:.2f} ns" -# -# -#def read_data_block_from_serial(the_serial, end_byte=0x0a, start_byte=None, max_read_time=None): -# """ -# This function reads some bytes from serial interface -# it returns an array of bytes if a timeout occurs or a given end byte is encountered -# and otherwise None if an error occurred -# :param the_serial: interface to read from -# :param end_byte: the indicator for end of data, this will be included in response -# :param start_byte: the indicator for start of data, this will be included in response -# :param max_read_time: -# :returns the read data or None -# """ -# logger.debug("start to read data from serial device") -# response = bytes() -# starttime = time.time() -# start_found = False -# try: -# while True: -# ch = the_serial.read() -# #logger.debug(f"Read {ch}") -# runtime = time.time() -# if len(ch) == 0: -# break -# if start_byte is not None: -# if ch == start_byte: -# response = bytes() -# start_found = True -# response += ch -# if ch == end_byte: -# if start_byte is not None and not start_found: -# response = bytes() -# continue -# else: -# break -# if (response[-1] == end_byte): -# break -# if max_read_time is not None: -# if runtime-starttime > max_read_time: -# break -# except Exception as e: -# logger.debug(f"Exception {e} occurred in read data block from serial") -# return None -# logger.debug(f"finished reading data from serial device after {len(response)} bytes") -# return response -# -## -## -## moved from ehz.py -## adjust/implement -## -## -# -# # TODO: make this config dict -# self._serial = None -# self._sock = None -# self._target = None -# self._dataoffset = 0 -# -# # Lookup table for smartmeter names to data format -# _sml_devices = { -# 'smart-meter-gateway-com-1': 'hex' -# } -# -#OBIS_TYPES = ('objName', 'status', 'valTime', 'unit', 'scaler', 'value', 'signature', 'obis', 'valueReal', 'unitName', 'actualTime') -# -#SML_START_SEQUENCE = bytearray.fromhex('1B 1B 1B 1B 01 01 01 01') -#SML_END_SEQUENCE = bytearray.fromhex('1B 1B 1B 1B 1A') -# -#UNITS = { # Blue book @ http://www.dlms.com/documentation/overviewexcerptsofthedlmsuacolouredbooks/index.html -# 1: 'a', 2: 'mo', 3: 'wk', 4: 'd', 5: 'h', 6: 'min.', 7: 's', 8: '°', 9: '°C', 10: 'currency', -# 11: 'm', 12: 'm/s', 13: 'm³', 14: 'm³', 15: 'm³/h', 16: 'm³/h', 17: 'm³/d', 18: 'm³/d', 19: 'l', 20: 'kg', -# 21: 'N', 22: 'Nm', 23: 'Pa', 24: 'bar', 25: 'J', 26: 'J/h', 27: 'W', 28: 'VA', 29: 'var', 30: 'Wh', -# 31: 'WAh', 32: 'varh', 33: 'A', 34: 'C', 35: 'V', 36: 'V/m', 37: 'F', 38: 'Ω', 39: 'Ωm²/h', 40: 'Wb', -# 41: 'T', 42: 'A/m', 43: 'H', 44: 'Hz', 45: 'Rac', 46: 'Rre', 47: 'Rap', 48: 'V²h', 49: 'A²h', 50: 'kg/s', -# 51: 'Smho' -#} -# -#def init(self): -# # TODO: move this to the SML module -# # set function pointers -# if device == "hex": -# self._sml_prepare = self._sml_prepareHex -# elif device == "raw": -# self._sml_prepare = self._sml_prepareRaw -# else: -# self.logger.warning(f"Device type \"{device}\" not supported - defaulting to \"raw\"") -# self._sml_prepare = self._prepareRaw -# -# self.logger.debug(f"Using SML CRC params poly={self.poly}, reflect_in={self.reflect_in}, xor_in={self.xor_in}, reflect_out={self.reflect_out}, xor_out={self.xor_out}, swap_crc_bytes={self.swap_crc_bytes}") -# -#def connect(self): -# if not self.alive: -# self.logger.info('connect called but plugin not running.') -# return -# -# self._target = None -# with self._lock: -# try: -# if self.serialport is not None: -# self._target = f'serial://{self.serialport}' -# self._serial = serial.Serial(self.serialport, 9600, serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE, timeout=self.timeout) -# elif self.host is not None: -# self._target = f'tcp://{self.host}:{self.port}' -# self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -# self._sock.settimeout(2) -# self._sock.connect((self.host, self.port)) -# self._sock.setblocking(False) -# except Exception as e: -# self.logger.error(f'Could not connect to {self._target}: {e}') -# return -# else: -# self.logger.info(f'Connected to {self._target}') -# self.connected = True -# -#def disconnect(self): -# if self.connected: -# with self._lock: -# try: -# self._serial.close() -# except Exception: -# pass -# self._serial = None -# try: -# self._sock.shutdown(socket.SHUT_RDWR) -# except Exception: -# pass -# self._sock = None -# -# self.logger.info('SML: Disconnected!') -# self.connected = False -# self._target = None -# -# -# def _read(self, length): -# total = bytes() -# self.logger.debug('Start read') -# if self._serial is not None: -# while True: -# ch = self._serial.read() -# # self.logger.debug(f"Read {ch=}") -# if len(ch) == 0: -# self.logger.debug('End read') -# return total -# total += ch -# if len(total) >= length: -# self.logger.debug('End read') -# return total -# elif self._sock is not None: -# while True: -# try: -# data = self._sock.recv(length) -# if data: -# total.append(data) -# except socket.error as e: -# if e.args[0] == errno.EAGAIN or e.args[0] == errno.EWOULDBLOCK: -# break -# else: -# raise e -# -# self.logger.debug('End read') -# return b''.join(total) -# -# def poll_device(self): -# """ -# Polls for updates of the device, called by the scheduler. -# """ -# -# # check if another cyclic cmd run is still active -# if self._parse_lock.acquire(timeout=1): -# try: -# self.logger.debug('Polling Smartmeter now') -# -# self.connect() -# if not self.connected: -# self.logger.error('Not connected, no query possible') -# return -# else: -# self.logger.debug('Connected, try to query') -# -# start = time.time() -# data_is_valid = False -# try: -# data = self._read(self.buffersize) -# if len(data) == 0: -# self.logger.error('Reading data from device returned 0 bytes!') -# return -# else: -# self.logger.debug(f'Read {len(data)} bytes') -# -# if START_SEQUENCE in data: -# prev, _, data = data.partition(START_SEQUENCE) -# self.logger.debug('Start sequence marker {} found'.format(''.join(' {:02x}'.format(x) for x in START_SEQUENCE))) -# if END_SEQUENCE in data: -# data, _, rest = data.partition(END_SEQUENCE) -# self.logger.debug('End sequence marker {} found'.format(''.join(' {:02x}'.format(x) for x in END_SEQUENCE))) -# self.logger.debug(f'Packet size is {len(data)}') -# if len(rest) > 3: -# filler = rest[0] -# self.logger.debug(f'{filler} fill byte(s) ') -# checksum = int.from_bytes(rest[1:3], byteorder='little') -# self.logger.debug(f'Checksum is {to_Hex(checksum)}') -# buffer = bytearray() -# buffer += START_SEQUENCE + data + END_SEQUENCE + rest[0:1] -# self.logger.debug(f'Buffer length is {len(buffer)}') -# self.logger.debug('Buffer: {}'.format(''.join(' {:02x}'.format(x) for x in buffer))) -# crc16 = algorithms.Crc(width=16, poly=self.poly, reflect_in=self.reflect_in, xor_in=self.xor_in, reflect_out=self.reflect_out, xor_out=self.xor_out) -# crc_calculated = crc16.table_driven(buffer) -# if not self.swap_crc_bytes: -# self.logger.debug(f'Calculated checksum is {to_Hex(crc_calculated)}, given CRC is {to_Hex(checksum)}') -# data_is_valid = crc_calculated == checksum -# else: -# self.logger.debug(f'Calculated and swapped checksum is {to_Hex(swap16(crc_calculated))}, given CRC is {to_Hex(checksum)}') -# data_is_valid = swap16(crc_calculated) == checksum -# else: -# self.logger.debug('Not enough bytes read at end to satisfy checksum calculation') -# return -# else: -# self.logger.debug('No End sequence marker found in data') -# else: -# self.logger.debug('No Start sequence marker found in data') -# except Exception as e: -# self.logger.error(f'Reading data from {self._target} failed with exception {e}') -# return -# -# if data_is_valid: -# self.logger.debug("Checksum was ok, now parse the data_package") -# try: -# values = self._parse(self._sml_prepare(data)) -# except Exception as e: -# self.logger.error(f'Preparing and parsing data failed with exception {e}') -# else: -# for obis in values: -# self.logger.debug(f'Entry {values[obis]}') -# -# if obis in self._items: -# for prop in self._items[obis]: -# for item in self._items[obis][prop]: -# try: -# value = values[obis][prop] -# except Exception: -# pass -# else: -# item(value, self.get_shortname()) -# else: -# self.logger.debug("Checksum was not ok, will not parse the data_package") -# -# cycletime = time.time() - start -# -# self.logger.debug(f"Polling Smartmeter done. Poll cycle took {cycletime} seconds.") -# finally: -# self.disconnect() -# self._parse_lock.release() -# else: -# self.logger.warning('Triggered poll_device, but could not acquire lock. Request will be skipped.') -# -# def _parse(self, data): -# # Search SML List Entry sequences like: -# # "77 07 81 81 c7 82 03 ff 01 01 01 01 04 xx xx xx xx" - manufacturer -# # "77 07 01 00 00 00 09 ff 01 01 01 01 0b xx xx xx xx xx xx xx xx xx xx 01" - server id -# # "77 07 01 00 01 08 00 ff 63 01 80 01 62 1e 52 ff 56 00 00 00 29 85 01" - active energy consumed -# # Details see http://wiki.volkszaehler.org/software/sml -# self.values = {} -# packetsize = 7 -# self.logger.debug('Data:{}'.format(''.join(' {:02x}'.format(x) for x in data))) -# self._dataoffset = 0 -# while self._dataoffset < len(data)-packetsize: -# -# # Find SML_ListEntry starting with 0x77 0x07 -# # Attention! The check for != 0xff was necessary because of a possible Client-ID set to 77 07 ff ff ff ff ff ff -# # which would be accidently interpreted as an OBIS value -# if data[self._dataoffset] == 0x77 and data[self._dataoffset+1] == 0x07 and data[self._dataoffset+2] != 0xff: -# packetstart = self._dataoffset -# self._dataoffset += 1 -# try: -# entry = { -# 'objName' : self._read_entity(data), -# 'status' : self._read_entity(data), -# 'valTime' : self._read_entity(data), -# 'unit' : self._read_entity(data), -# 'scaler' : self._read_entity(data), -# 'value' : self._read_entity(data), -# 'signature' : self._read_entity(data) -# } -# -# # Decoding status information if present -# if entry['status'] is not None: -# entry['statRun'] = True if ((entry['status'] >> 8) & 1) == 1 else False # True: meter is counting, False: standstill -# entry['statFraudMagnet'] = True if ((entry['status'] >> 8) & 2) == 2 else False # True: magnetic manipulation detected, False: ok -# entry['statFraudCover'] = True if ((entry['status'] >> 8) & 4) == 4 else False # True: cover manipulation detected, False: ok -# entry['statEnergyTotal'] = True if ((entry['status'] >> 8) & 8) == 8 else False # Current flow total. True: -A, False: +A -# entry['statEnergyL1'] = True if ((entry['status'] >> 8) & 16) == 16 else False # Current flow L1. True: -A, False: +A -# entry['statEnergyL2'] = True if ((entry['status'] >> 8) & 32) == 32 else False # Current flow L2. True: -A, False: +A -# entry['statEnergyL3'] = True if ((entry['status'] >> 8) & 64) == 64 else False # Current flow L3. True: -A, False: +A -# entry['statRotaryField'] = True if ((entry['status'] >> 8) & 128) == 128 else False # True: rotary field not L1->L2->L3, False: ok -# entry['statBackstop'] = True if ((entry['status'] >> 8) & 256) == 256 else False # True: backstop active, False: backstop not active -# entry['statCalFault'] = True if ((entry['status'] >> 8) & 512) == 512 else False # True: calibration relevant fatal fault, False: ok -# entry['statVoltageL1'] = True if ((entry['status'] >> 8) & 1024) == 1024 else False # True: Voltage L1 present, False: not present -# entry['statVoltageL2'] = True if ((entry['status'] >> 8) & 2048) == 2048 else False # True: Voltage L2 present, False: not present -# entry['statVoltageL3'] = True if ((entry['status'] >> 8) & 4096) == 4096 else False # True: Voltage L3 present, False: not present -# -# # Add additional calculated fields -# entry['obis'] = f"{entry['objName'][0]}-{entry['objName'][1]}:{entry['objName'][2]}.{entry['objName'][3]}.{entry['objName'][4]}*{entry['objName'][5]}" -# entry['valueReal'] = round(entry['value'] * 10 ** entry['scaler'], 1) if entry['scaler'] is not None else entry['value'] -# entry['unitName'] = UNITS[entry['unit']] if entry['unit'] is not None and entry['unit'] in UNITS else None -# entry['actualTime'] = time.ctime(self.date_offset + entry['valTime'][1]) if entry['valTime'] is not None else None # Decodes valTime into date/time string -# # For a Holley DTZ541 with faulty Firmware remove the ^[1] from this line ^. -# -# # Convert some special OBIS values into nicer format -# # EMH ED300L: add additional OBIS codes -# if entry['obis'] == '1-0:0.2.0*0': -# entry['valueReal'] = entry['value'].decode() # Firmware as UTF-8 string -# if entry['obis'] == '1-0:96.50.1*1' or entry['obis'] == '129-129:199.130.3*255': -# entry['valueReal'] = entry['value'].decode() # Manufacturer code as UTF-8 string -# if entry['obis'] == '1-0:96.1.0*255' or entry['obis'] == '1-0:0.0.9*255': -# entry['valueReal'] = entry['value'].hex() # ServerID (Seriel Number) as hex string as found on frontpanel -# if entry['obis'] == '1-0:96.5.0*255': -# entry['valueReal'] = bin(entry['value'] >> 8) # Status as binary string, so not decoded into status bits as above -# -# entry['objName'] = entry['obis'] # Changes objName for DEBUG output to nicer format -# -# self.values[entry['obis']] = entry -# -# except Exception as e: -# if self._dataoffset < len(data) - 1: -# self.logger.warning('Cannot parse entity at position {}, byte {}: {}:{}...'.format(self._dataoffset, self._dataoffset - packetstart, e, ''.join(' {:02x}'.format(x) for x in data[packetstart:packetstart+64]))) -# self._dataoffset = packetstart + packetsize - 1 -# else: -# self._dataoffset += 1 -# -# return self.values -# -# def _read_entity(self, data): -# import builtins -# upack = { -# 5: {1: '>b', 2: '>h', 4: '>i', 8: '>q'}, # int -# 6: {1: '>B', 2: '>H', 4: '>I', 8: '>Q'} # uint -# } -# -# result = None -# -# tlf = data[self._dataoffset] -# type = (tlf & 112) >> 4 -# more = tlf & 128 -# len = tlf & 15 -# self._dataoffset += 1 -# -# if more > 0: -# tlf = data[self._dataoffset] -# len = (len << 4) + (tlf & 15) -# self._dataoffset += 1 -# -# len -= 1 -# -# if len == 0: # Skip empty optional value -# return result -# -# if self._dataoffset + len >= builtins.len(data): -# raise Exception(f"Try to read {len} bytes, but only got {builtins.len(data) - self._dataoffset}") -# -# if type == 0: # Octet string -# result = data[self._dataoffset:self._dataoffset+len] -# -# elif type == 5 or type == 6: # int or uint -# d = data[self._dataoffset:self._dataoffset+len] -# -# ulen = len -# if ulen not in upack[type]: # Extend to next greather unpack unit -# while ulen not in upack[type]: -# d = b'\x00' + d -# ulen += 1 -# -# result = struct.unpack(upack[type][ulen], d)[0] -# -# elif type == 7: # list -# result = [] -# self._dataoffset += 1 -# for i in range(0, len + 1): -# result.append(self._read_entity(data)) -# return result -# -# else: -# self.logger.warning(f'Skipping unknown field {hex(tlf)}') -# -# self._dataoffset += len -# -# return result -# -# def _prepareRaw(self, data): -# return data -# -# def _prepareHex(self, data): -# data = data.decode("iso-8859-1").lower() -# data = re.sub("[^a-f0-9]", " ", data) -# data = re.sub("( +[a-f0-9]|[a-f0-9] +)", "", data) -# data = data.encode() -# return bytes(''.join(chr(int(data[i:i+2], 16)) for i in range(0, len(data), 2)), "iso8859-1") -# -# -########################################################### -## Helper Functions -########################################################### -# -# -#def to_Hex(data): -# """ -# Returns the hex representation of the given data -# """ -# # try: -# # return data.hex() -# # except: -# # return "".join("%02x " % b for b in data).rstrip() -# # logger.debug("Hextype: {}".format(type(data))) -# if isinstance(data, int): -# return hex(data) -# -# return "".join("%02x " % b for b in data).rstrip() -# -# -#def swap16(x): -# return (((x << 8) & 0xFF00) | -# ((x >> 8) & 0x00FF)) -# -# -#def swap32(x): -# return (((x << 24) & 0xFF000000) | -# ((x << 8) & 0x00FF0000) | -# ((x >> 8) & 0x0000FF00) | -# ((x >> 24) & 0x000000FF)) -# -## -## -## -## -## -## -# -# -#def query( config ): -# """ -# This function will -# 1. open a serial communication line to the smartmeter -# 2. sends a request for info -# 3. parses the devices first (and maybe second) answer for capabilities of the device -# 4. adjusts the speed of the communication accordingly -# 5. reads out the block of OBIS information -# 6. closes the serial communication -# -# config contains a dict with entries for -# 'serialport', 'device', 'querycode', 'baudrate', 'baudrate_fix', 'timeout', 'onlylisten', 'use_checksum' -# -# return: a textblock with the data response from smartmeter -# """ -# # for the performance of the serial read we need to save the actual time -# starttime = time.time() -# runtime = starttime -# result = None -# -# -# SerialPort = config.get('serialport') -# Device = config.get('device','') -# InitialBaudrate = config.get('baudrate', 300) -# QueryCode = config.get('querycode', '?') -# use_checksum = config.get('use_checksum', True) -# baudrate_fix = config.get('baudrate_fix', False) -# timeout = config.get('timeout', 3) -# OnlyListen = config.get('onlylisten', False) # just for the case that smartmeter transmits data without a query first -# logger.debug(f"Config='{config}'") -# StartChar = b'/'[0] -# -# Request_Message = b"/"+QueryCode.encode('ascii')+Device.encode('ascii')+b"!\r\n" -# -# -# # open the serial communication -# # about timeout: time tr between sending a request and an answer needs to be -# # 200ms < tr < 1500ms for protocol mode A or B -# # inter character time must be smaller than 1500 ms -# # The time between the reception of a message and the transmission of an answer is: -# # (20 ms) 200 ms = tr = 1 500 ms (see item 12) of 6.3.14). -# # If a response has not been received, the waiting time of the transmitting equipment after -# # transmission of the identification message, before it continues with the transmission, is: -# # 1 500 ms < tt = 2 200 ms -# # The time between two characters in a character sequence is: -# # ta < 1 500 ms -# wait_before_acknowledge = 0.4 # wait for 400 ms before sending the request to change baudrate -# wait_after_acknowledge = 0.4 # wait for 400 ms after sending acknowledge -# sml_serial = None -# -# try: -# sml_serial = serial.Serial(SerialPort, -# InitialBaudrate, -# bytesize=serial.SEVENBITS, -# parity=serial.PARITY_EVEN, -# stopbits=serial.STOPBITS_ONE, -# timeout=timeout) -# if not SerialPort == sml_serial.name: -# logger.debug(f"Asked for {SerialPort} as serial port, but really using now {sml_serial.name}") -# -# except FileNotFoundError as e: -# logger.error(f"Serial port '{SerialPort}' does not exist, please check your port") -# return -# except OSError as e: -# logger.error(f"Serial port '{SerialPort}' does not exist, please check the spelling") -# return -# except serial.SerialException as e: -# if sml_serial is None: -# logger.error(f"Serial port '{SerialPort}' could not be opened") -# else: -# logger.error(f"Serial port '{SerialPort}' could be opened but somehow not accessed") -# except Exception as e: -# logger.error(f"Another unknown error occurred: '{e}'") -# return -# -# if not sml_serial.isOpen(): -# logger.error(f"Serial port '{SerialPort}' could not be opened with given parameters, maybe wrong baudrate?") -# return -# -# logger.debug(f"Time to open serial port {SerialPort}: {format_time(time.time()- runtime)}") -# runtime = time.time() -# -# Acknowledge = b'' # preset empty answer -# -# if not OnlyListen: -# # start a dialog with smartmeter -# try: -# #logger.debug(f"Reset input buffer from serial port '{SerialPort}'") -# #sml_serial.reset_input_buffer() # replaced sml_serial.flushInput() -# logger.debug(f"Writing request message {Request_Message} to serial port '{SerialPort}'") -# sml_serial.write(Request_Message) -# #logger.debug(f"Flushing buffer from serial port '{SerialPort}'") -# #sml_serial.flush() # replaced sml_serial.drainOutput() -# except Exception as e: -# logger.warning(f"Error {e}") -# return -# -# logger.debug(f"Time to send first request to smartmeter: {format_time(time.time()- runtime)}") -# -# # now get first response -# response = read_data_block_from_serial(sml_serial) -# if response is None: -# logger.debug("No response received upon first request") -# return -# -# logger.debug(f"Time to receive an answer: {format_time(time.time()- runtime)}") -# runtime = time.time() -# -# # We need to examine the read response here for an echo of the _Request_Message -# # some meters answer with an echo of the request Message -# if response == Request_Message: -# logger.debug("Request Message was echoed, need to read the identification message") -# # read Identification message if Request was echoed -# # now read the capabilities and type/brand line from Smartmeter -# # e.g. b'/LGZ5\\2ZMD3104407.B32\r\n' -# response = read_data_block_from_serial(sml_serial) -# else: -# logger.debug("Request Message was not equal to response, treating as identification message") -# -# logger.debug(f"Time to get first identification message from smartmeter: {format_time(time.time() - runtime)}") -# runtime = time.time() -# -# Identification_Message = response -# logger.debug(f"Identification Message is {Identification_Message}") -# -# # need at least 7 bytes: -# # 1 byte "/" -# # 3 bytes short Identification -# # 1 byte speed indication -# # 2 bytes CR LF -# if (len(Identification_Message) < 7): -# logger.warning(f"malformed identification message: '{Identification_Message}', abort query") -# return -# -# if (Identification_Message[0] != StartChar): -# logger.warning(f"identification message '{Identification_Message}' does not start with '/', abort query") -# return -# -# manid = str(Identification_Message[1:4],'utf-8') -# manname = manufacturer_ids.get(manid,'unknown') -# logger.debug(f"The manufacturer for {manid} is {manname} (out of {len(manufacturer_ids)} given manufacturers)") -# -# """ -# Different smartmeters allow for different protocol modes. -# The protocol mode decides whether the communication is fixed to a certain baudrate or might be speed up. -# Some meters do initiate a protocol by themselves with a fixed speed of 2400 baud e.g. Mode D -# However some meters specify a speed of 9600 Baud although they use protocol mode D (readonly) -# """ -# Protocol_Mode = 'A' -# -# """ -# The communication of the plugin always stays at the same speed, -# Protocol indicator can be anything except for A-I, 0-9, /, ? -# """ -# Baudrates_Protocol_Mode_A = 300 -# Baudrates_Protocol_Mode_B = { 'A': 600, 'B': 1200, 'C': 2400, 'D': 4800, 'E': 9600, 'F': 19200, -# 'G': "reserved", 'H': "reserved", 'I': "reserved" } -# Baudrates_Protocol_Mode_C = { '0': 300, '1': 600, '2': 1200, '3': 2400, '4': 4800, '5': 9600, '6': 19200, -# '7': "reserved", '8': "reserved", '9': "reserved"} -# -# # always '3' but it is always initiated by the metering device so it can't be encountered here -# Baudrates_Protocol_Mode_D = { '3' : 2400} -# Baudrates_Protocol_Mode_E = Baudrates_Protocol_Mode_C -# -# Baudrate_identification = chr(Identification_Message[4]) -# if Baudrate_identification in Baudrates_Protocol_Mode_B: -# NewBaudrate = Baudrates_Protocol_Mode_B[Baudrate_identification] -# Protocol_Mode = 'B' -# elif Baudrate_identification in Baudrates_Protocol_Mode_C: -# NewBaudrate = Baudrates_Protocol_Mode_C[Baudrate_identification] -# Protocol_Mode = 'C' # could also be 'E' but it doesn't make any difference here -# else: -# NewBaudrate = Baudrates_Protocol_Mode_A -# Protocol_Mode = 'A' -# -# logger.debug(f"Baudrate id is '{Baudrate_identification}' thus Protocol Mode is {Protocol_Mode} and suggested Baudrate is {NewBaudrate} Bd") -# -# if chr(Identification_Message[5]) == '\\': -# if chr(Identification_Message[6]) == '2': -# logger.debug("HDLC protocol could be used if it was implemented") -# else: -# logger.debug("Another protocol could probably be used if it was implemented") -# -# # for protocol C or E we now send an acknowledge and include the new baudrate parameter -# # maybe todo -# # we could implement here a baudrate that is fixed to somewhat lower speed if we need to -# # read out a smartmeter with broken communication -# Action = b'0' # Data readout, possible are also b'1' for programming mode or some manufacturer specific -# Acknowledge = b'\x060'+ Baudrate_identification.encode() + Action + b'\r\n' -# -# if Protocol_Mode == 'C': -# # the speed change in communication is initiated from the reading device -# time.sleep(wait_before_acknowledge) -# logger.debug(f"Using protocol mode C, send acknowledge {Acknowledge} and tell smartmeter to switch to {NewBaudrate} Baud") -# try: -# sml_serial.write( Acknowledge ) -# except Exception as e: -# logger.warning(f"Warning {e}") -# return -# time.sleep(wait_after_acknowledge) -# #sml_serial.flush() -# #sml_serial.reset_input_buffer() -# if (NewBaudrate != InitialBaudrate): -# # change request to set higher baudrate -# sml_serial.baudrate = NewBaudrate -# -# elif Protocol_Mode == 'B': -# # the speed change in communication is initiated from the smartmeter device -# time.sleep(wait_before_acknowledge) -# logger.debug(f"Using protocol mode B, smartmeter and reader will switch to {NewBaudrate} Baud") -# time.sleep(wait_after_acknowledge) -# #sml_serial.flush() -# #sml_serial.reset_input_buffer() -# if (NewBaudrate != InitialBaudrate): -# # change request to set higher baudrate -# sml_serial.baudrate = NewBaudrate -# else: -# logger.debug(f"No change of readout baudrate, " -# "smartmeter and reader will stay at {NewBaudrate} Baud") -# -# # now read the huge data block with all the OBIS codes -# logger.debug("Reading OBIS data from smartmeter") -# response = read_data_block_from_serial(sml_serial, None) -# else: -# # only listen mode, starts with / and last char is ! -# # data will be in between those two -# response = read_data_block_from_serial(sml_serial, b'!', b'/') -# -# Identification_Message = str(response,'utf-8').splitlines()[0] -# -# manid = Identification_Message[1:4] -# manname = manufacturer_ids.get(manid,'unknown') -# logger.debug(f"The manufacturer for {manid} is {manname} (out of {len(manufacturer_ids)} given manufacturers)") -# -# -# sml_serial.close() -# logger.debug(f"Time for reading OBIS data: {format_time(time.time()- runtime)}") -# runtime = time.time() -# -# # Display performance of the serial communication -# logger.debug(f"Whole communication with smartmeter took {format_time(time.time() - starttime)}") -# -# if response.startswith(Acknowledge): -# if not OnlyListen: -# logger.debug("Acknowledge echoed from smartmeter") -# response = response[len(Acknowledge):] -# -# if use_checksum: -# # data block in response may be capsuled within STX and ETX to provide error checking -# # thus the response will contain a sequence of -# # STX Datablock ! CR LF ETX BCC -# # which means we need at least 6 characters in response where Datablock is empty -# logger.debug("trying now to calculate a checksum") -# -# if response[0] == STX: -# logger.debug("STX found") -# else: -# logger.warning(f"STX not found in response='{' '.join(hex(i) for i in response[:10])}...'") -# -# if response[-2] == ETX: -# logger.debug("ETX found") -# else: -# logger.warning(f"ETX not found in response='...{' '.join(hex(i) for i in response[-11])}'") -# -# if (len(response) > 5) and (response[0] == STX) and (response[-2] == ETX): -# # perform checks (start with char after STX, end with ETX including, checksum matches last byte (BCC)) -# BCC = response[-1] -# logger.debug(f"block check character BCC is {BCC}") -# checksum = 0 -# for i in response[1:-1]: -# checksum ^= i -# if checksum != BCC: -# logger.warning(f"checksum/protocol error: response={' '.join(hex(i) for i in response[1:-1])} " -# "checksum={checksum}") -# return -# else: -# logger.debug("checksum over data response was ok, data is valid") -# else: -# logger.warning("STX - ETX not found") -# else: -# logger.debug("checksum calculation skipped") -# -# if not OnlyListen: -# if len(response) > 5: -# result = str(response[1:-4], 'ascii') -# logger.debug(f"parsing OBIS codes took {format_time(time.time()- runtime)}") -# else: -# logger.debug("Sorry response did not contain enough data for OBIS decode") -# else: -# result = str(response, 'ascii') -# -# suggested_cycle = (time.time() - starttime) + 10.0 -# config['suggested_cycle'] = suggested_cycle -# logger.debug(f"the whole query took {format_time(time.time()- starttime)}, suggested cycle thus is at least {format_time(suggested_cycle)}") -# return result -# -#if __name__ == '__main__': -# import sys -# import argparse -# -# parser = argparse.ArgumentParser(description='Query a smartmeter at a given port for SML output', -# usage='use "%(prog)s --help" for more information', -# formatter_class=argparse.RawTextHelpFormatter) -# parser.add_argument('port', help='specify the port to use for the smartmeter query, e.g. /dev/ttyUSB0 or /dev/sml0') -# parser.add_argument('-v', '--verbose', help='print verbose information', action='store_true') -# parser.add_argument('-t', '--timeout', help='maximum time to wait for a message from the smartmeter', type=float, default=3.0 ) -# parser.add_argument('-b', '--baudrate', help='initial baudrate to start the communication with the smartmeter', type=int, default=300 ) -# parser.add_argument('-d', '--device', help='give a device address to include in the query', default='' ) -# parser.add_argument('-q', '--querycode', help='define alternative query code\ndefault query code is ?\nsome smartmeters provide additional information when sending\nan alternative query code, e.g. 2 instead of ?', default='?' ) -# parser.add_argument('-l', '--onlylisten', help='Only listen to serial, no active query', action='store_true' ) -# parser.add_argument('-f', '--baudrate_fix', help='Keep baudrate speed fixed', action='store_false' ) -# parser.add_argument('-c', '--nochecksum', help='use a checksum', action='store_false' ) -# -# args = parser.parse_args() -# -# config = {} -# -# config['serialport'] = args.port -# config['device'] = args.device -# config['querycode'] = args.querycode -# config['baudrate'] = args.baudrate -# config['baudrate_fix'] = args.baudrate_fix -# config['timeout'] = args.timeout -# config['onlylisten'] = args.onlylisten -# config['use_checksum'] = args.nochecksum -# -# if args.verbose: -# logging.getLogger().setLevel( logging.DEBUG ) -# ch = logging.StreamHandler() -# ch.setLevel(logging.DEBUG) -# # create formatter and add it to the handlers -# formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s @ %(lineno)d') -# #formatter = logging.Formatter('%(message)s') -# ch.setFormatter(formatter) -# # add the handlers to the logger -# logging.getLogger().addHandler(ch) -# else: -# logging.getLogger().setLevel( logging.DEBUG ) -# ch = logging.StreamHandler() -# ch.setLevel(logging.DEBUG) -# # just like print -# formatter = logging.Formatter('%(message)s') -# ch.setFormatter(formatter) -# # add the handlers to the logger -# logging.getLogger().addHandler(ch) -# +# needed for old parser # -# logger.info("This is SML Plugin running in standalone mode") -# logger.info("==============================================") +# def swap16(x: int) -> int: +# return (((x << 8) & 0xFF00) | ((x >> 8) & 0x00FF)) # -# result = query(config) -# -# if result is None: -# logger.info(f"No results from query, maybe a problem with the serial port '{config['serialport']}' given ") -# logger.info("==============================================") -# elif len(result) > 0: -# logger.info("These are the results of the query") -# logger.info("==============================================") -# logger.info(result) -# logger.info("==============================================") -# else: -# logger.info("The query did not get any results!") -# logger.info("Maybe the serial was occupied or there was an error") +# def swap32(x): +# return (((x << 24) & 0xFF000000) | ((x << 8) & 0x00FF0000) | ((x >> 8) & 0x0000FF00) | ((x >> 24) & 0x000000FF)) # -# \ No newline at end of file +# def prepareHex(data: bytes) -> bytes: +# data2 = data.decode("iso-8859-1").lower() +# data2 = re.sub("[^a-f0-9]", " ", data2) +# data2 = re.sub("( +[a-f0-9]|[a-f0-9] +)", "", data2) +# data2 = data2.encode() +# return bytes(''.join(chr(int(data[i:i + 2], 16)) for i in range(0, len(data), 2)), "iso8859-1") + + +def query(config) -> dict: + """ + This function will + 1. open a serial communication line to the smartmeter + 2. reads out the block of OBIS information + 3. closes the serial communication + 4. extract obis data and format return dict + + config contains a dict with entries for + 'serial_port', 'device' and a sub-dict 'sml' with entries for + 'device', 'buffersize', 'date_offset' and additional entries for + calculating crc ('poly', 'reflect_in', 'xor_in', 'reflect_out', 'xor_out', 'swap_crc_bytes') + + return: a dict with the response data formatted as follows: + { + 'readout': , + '': [{'value': , (optional) 'unit': ''}, {'value': ', 'unit': ''}, ...], + '': [...], + ... + } + """ + + # TODO: modularize; find components to reuse with DLMS? + + # + # initialize module + # + + # for the performance of the serial read we need to save the current time + starttime = time.time() + runtime = starttime + result = {} + lock = Lock() + + try: + serial_port = config.get('serial_port') + host = config.get('host') + port = config.get('port') + timeout = config['timeout'] + + buffersize = config['sml']['buffersize'] + + # needed for old parser + # device = config['sml']['device'] + # date_offset = config['sml']['date_offset'] + # + # poly = config['sml']['poly'] + # reflect_in = config['sml']['reflect_in'] + # xor_in = config['sml']['xor_in'] + # reflect_out = config['sml']['reflect_out'] + # xor_out = config['sml']['xor_out'] + # swap_crc_bytes = config['sml']['swap_crc_bytes'] + + except (KeyError, AttributeError) as e: + logger.warning(f'configuration {config} is missing elements: {e}') + return {} + + logger.debug(f"config='{config}'") + + locked = lock.acquire(blocking=False) + if not locked: + logger.error('could not get lock for serial/network access. Is another scheduled/manual action still active?') + return {} + + locked = lock.acquire(blocking=False) + try: # lock release + sock = None + if serial_port and not TESTING: + # + # open the serial communication + # + try: # open serial + sock = serial.Serial(serial_port, 9600, serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE, timeout=timeout) + if not serial_port == sock.name: + logger.debug(f"Asked for {serial_port} as serial port, but really using now {sock.name}") + target = f'serial://{sock.name}' + + except FileNotFoundError: + logger.error(f"Serial port '{serial_port}' does not exist, please check your port") + return {} + except serial.SerialException: + if sock is None: + logger.error(f"Serial port '{serial_port}' could not be opened") + else: + logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") + return {} + except OSError: + logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") + return {} + except Exception as e: + logger.error(f"unforeseen error occurred: '{e}'") + return {} + + if sock is None: + # this should not happen... + logger.error("unforeseen error occurred, serial object was not initialized.") + return {} + + if not sock.is_open: + logger.error(f"serial port '{serial_port}' could not be opened with given parameters, maybe wrong baudrate?") + return {} + + logger.debug(f"time to open serial port {serial_port}: {format_time(time.time() - runtime)}") + runtime = time.time() + elif host and not TESTING: + # + # open network connection + # + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + sock.connect((host, port)) + sock.setblocking(False) + target = f'tcp://{host}:{port}' + + elif not TESTING: + logger.error('neither serialport nor host/port was given, no action possible.') + return {} + + # + # read data from device + # + data = bytes() + try: + data = read(sock, buffersize) + if len(data) == 0: + logger.error('reading data from device returned 0 bytes!') + return {} + else: + logger.debug(f'read {len(data)} bytes') + + except Exception as e: + logger.error(f'reading data from {target} failed with error: {e}') + + except Exception: + # passthrough, this is only for releasing the lock + raise + finally: + # + # clean up connection + # + try: + sock.close() + except Exception: + pass + sock = None + lock.release() + + logger.debug(f"time for reading OBIS data: {format_time(time.time() - runtime)}") + runtime = time.time() + + # Display performance of the serial communication + logger.debug(f"whole communication with smartmeter took {format_time(time.time() - starttime)}") + + # + # parse data + # + + stream = SmlStreamReader() + stream.add(data) + + while True: + try: + frame = stream.get_frame() + if frame is None: + break + + obis_values = frame.get_obis() + for sml_entry in obis_values: + code = sml_entry.obis.obis_code + if code not in result: + result[code] = [] + content = { + 'value': sml_entry.get_value(), + 'name': OBIS_NAMES.get(sml_entry.obis), + 'scaler': sml_entry.scaler, + 'status': sml_entry.status, + 'valTime': sml_entry.val_time, + 'signature': sml_entry.value_signature + } + if sml_entry.unit: + content['unit'] = smlConst.UNITS.get(sml_entry.unit) + result[code].append(content) + + except Exception as e: + detail = traceback.format_exc() + logger.warning(f'parsing data failed with error: {e}; details are {detail}') + # at least return what was decoded up to now + return result + + # old frame parser, possibly remove later (needs add'l helper and not-presend "parse" routine) + # if START_SEQUENCE in data: + # prev, _, data = data.partition(START_SEQUENCE) + # logger.debug(f'start sequence marker {to_hex(START_SEQUENCE)} found') + # if END_SEQUENCE in data: + # data, _, remainder = data.partition(END_SEQUENCE) + # logger.debug(f'end sequence marker {to_hex(END_SEQUENCE)} found') + # logger.debug(f'packet size is {len(data)}') + # if len(remainder) > 3: + # filler = remainder[0] + # logger.debug(f'{filler} fill byte(s) ') + # checksum = int.from_bytes(remainder[1:3], byteorder='little') + # logger.debug(f'Checksum is {to_hex(checksum)}') + # buffer = bytearray() + # buffer += START_SEQUENCE + data + END_SEQUENCE + remainder[0:1] + # logger.debug(f'Buffer length is {len(buffer)}') + # logger.debug(f'buffer is: {to_hex(buffer)}') + # crc16 = Crc(width=16, poly=poly, reflect_in=reflect_in, xor_in=xor_in, reflect_out=reflect_out, xor_out=xor_out) + # crc_calculated = crc16.table_driven(buffer) + # if swap_crc_bytes: + # logger.debug(f'calculated swapped checksum is {to_hex(swap16(crc_calculated))}, given CRC is {to_hex(checksum)}') + # crc_calculated = swap16(crc_calculated) + # else: + # logger.debug(f'calculated checksum is {to_hex(crc_calculated)}, given CRC is {to_hex(checksum)}') + # data_is_valid = crc_calculated == checksum + # else: + # logger.debug('not enough bytes read at end to satisfy checksum calculation') + # else: + # logger.debug('no End sequence marker found in data') + # else: + # logger.debug('no Start sequence marker found in data') + + return result + + +def discover(config: dict) -> bool: + """ try to autodiscover SML protocol """ + + # as of now, this simply tries to listen to the meter + # called from within the plugin, the parameters are either manually set by + # the user, or preset by the plugin.yaml defaults. + # If really necessary, the query could be called multiple times with + # reduced baud rates or changed parameters, but there would need to be + # the need for this. + # For now, let's see how well this works... + result = query(config) + + return result != {} + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description='Query a smartmeter at a given port for SML output', + usage='use "%(prog)s --help" for more information', + formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument('port', help='specify the port to use for the smartmeter query, e.g. /dev/ttyUSB0 or /dev/sml0') + parser.add_argument('-v', '--verbose', help='print verbose information', action='store_true') + parser.add_argument('-t', '--timeout', help='maximum time to wait for a message from the smartmeter', type=float, default=3.0) + parser.add_argument('-b', '--buffersize', help='maximum size of message buffer for the reply', type=int, default=1024) + + args = parser.parse_args() + + config = {} + + config['serial_port'] = args.port + config['timeout'] = args.timeout + config['sml'] = {'buffersize': args.buffersize} + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + # create formatter and add it to the handlers + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s @ %(lineno)d') + # formatter = logging.Formatter('%(message)s') + ch.setFormatter(formatter) + # add the handlers to the logger + logging.getLogger().addHandler(ch) + else: + logging.getLogger().setLevel(logging.DEBUG) + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + # just like print + formatter = logging.Formatter('%(message)s') + ch.setFormatter(formatter) + # add the handlers to the logger + logging.getLogger().addHandler(ch) + + logger.info("This is Smartmeter Plugin, SML module, running in standalone mode") + logger.info("==================================================================") + + result = query(config) + + if not result: + logger.info(f"No results from query, maybe a problem with the serial port '{config['serial_port']}' given.") + elif len(result) > 1: + logger.info("These are the processed results of the query:") + try: + del result['readout'] + except KeyError: + pass + logger.info(result) + elif len(result) == 1: + logger.info("The results of the query could not be processed; raw result is:") + logger.info(result) + else: + logger.info("The query did not get any results. Maybe the serial port was occupied or there was an error.") diff --git a/smartmeter/sml_test.py b/smartmeter/sml_test.py new file mode 100644 index 000000000..15d365fc6 --- /dev/null +++ b/smartmeter/sml_test.py @@ -0,0 +1,363 @@ +HEIZUNG = [ + b'\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x0050b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcd\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xa4p\x00v\x05\x1a\x0051b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xd7zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01', + b'\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3i\xeb\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3i\xeb\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00', + b'\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16\xe1\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07(\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07I\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08p\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?', + b"\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01ch\xe1\x00v\x05\x1a\x0052b\x00b\x00rc\x02\x01q\x01c\xddZ\x00\x1b\x1b\x1b\x1b\x1a\x00]x\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x0053b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbce\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c[o\x00v\x05\x1a\x0054b\x00b\x00", + b'rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xd8zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3i', + b'\xfc\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3i\xfc\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16\xa3\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\r\x01w\x07\x01\x008\x07\x00\xff\x01\x01', + b"b\x1bR\x00U\x00\x00\x07?\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08W\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\x0e\xf0\x00v", + b'\x05\x1a\x0055b\x00b\x00rc\x02\x01q\x01c;\xfa\x00\x1b\x1b\x1b\x1b\x1a\x00t\xce\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x0056b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcf\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01cZO\x00v\x05\x1a\x0057b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!', + b'\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xd9zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\r\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xff', + b'Y\x00\x00\x00\x00\x17\xb3j\r\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16\x7f\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\t\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x079\x01w\x07\x01\x00L\x07', + b"\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08<\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\x17J\x00v\x05\x1a\x0058b\x00b\x00rc\x02\x01q\x01c", + b'UM\x00\x1b\x1b\x1b\x1b\x1a\x00v\x84\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x0059b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcg\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xe0\xdc\x00v\x05\x1a\x005:b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xda', + b'zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\x1d\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\x1d\x01w\x07\x01\x00\x01\x08', + b'\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16\x97\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x11\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x074\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08O\x01w\x07', + b"\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\x98\xfd\x00v\x05\x1a\x005;b\x00b\x00rc\x02\x01q\x01c\xe6\xb3\x00\x1b\x1b\x1b\x1b\x1a\x00\xd4\xb5\x1b\x1b\x1b\x1b\x01", + b'\x01\x01\x01v\x05\x1a\x005A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xbd\xd5\x00v\x05\x1a\x005>b\x00b\x00rc\x02\x01q\x01c"\xb8\x00\x1b\x1b\x1b\x1b\x1a\x00\xd7\xb5\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005?b\x00b\x00rc\x01', + b'\x01v\x01\x01\x05\x08\xaa\xbci\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xc4\xff\x00v\x05\x1a\x005@b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xdczw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t', + b'\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j>\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j>\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00', + b"U\x00\x00\x16K\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x02\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x1e\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x087\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf", + b'!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xf1\xf8\x00v\x05\x1a\x005Ab\x00b\x00rc\x02\x01q\x01c\xa4\xfa\x00\x1b\x1b\x1b\x1b\x1a\x00\t\xc4\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005Bb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcj\x0b\t\x01ISK\x00', + b'\x03\xcf!\x02\x01\x01c2\x8d\x00v\x05\x1a\x005Cb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xddzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08', + b'\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3jO\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3jO\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16e\x01w\x07\x01\x00$\x07\x00\xff\x01\x01', + b"b\x1bR\x00U\x00\x00\x07\x07\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07'\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x086\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89", + b'\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01ccK\x00v\x05\x1a\x005Db\x00b\x00rc\x02\x01q\x01c`\xf1\x00\x1b\x1b\x1b\x1b\x1a\x00\xf1r\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005Eb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbck\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\x01\x11\x00v\x05\x1a\x005F', + b'b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xdezw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00', + b'\x00\x17\xb3j_\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j_\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16#\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xdd\x01w\x07\x01\x008\x07', + # b"\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x07\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08>\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c", + # b'\xae\x96\x00v\x05\x1a\x005Gb\x00b\x00rc\x02\x01q\x01c\xd3\x0f\x00\x1b\x1b\x1b\x1b\x1a\x00\xbdb\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005Hb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcl\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c0\xcd\x00v\x05\x1a\x005Ib\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK', + # b'\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xdfzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3jo\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01', + # b'b\x1eR\xffY\x00\x00\x00\x00\x17\xb3jo\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16O\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x19\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\n\x01w\x07', + # b"\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08+\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c&]\x00v\x05\x1a\x005Jb\x00b\x00rc\x02", + # b'\x01q\x01c\xbd\xb8\x00\x1b\x1b\x1b\x1b\x1a\x00N\xea\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005Kb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcm\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xcf\xd2\x00v\x05\x1a\x005Lb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e', + # b'\x12\xda\x9b\xe0zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\x80\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\x80\x01w\x07', + # b'\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16{\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x1a\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x1e\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08', + # b"A\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01ca\x88\x00v\x05\x1a\x005Mb\x00b\x00rc\x02\x01q\x01c[\x18\x00\x1b\x1b\x1b\x1b\x1a\x00\xc4U\x1b", + # b'\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005Nb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcn\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xce\xf2\x00v\x05\x1a\x005Ob\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xe1zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01', + # b'\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\x90\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\x90\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00', + # b'\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16\x81\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x1b\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x077\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08-\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02', + # b"?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xdb\x1b\x00v\x05\x1a\x005Pb\x00b\x00rc\x02\x01q\x01cp\xde\x00\x1b\x1b\x1b\x1b\x1a\x00k\xa3\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005Qb\x00b", + # b'\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbco\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01cf~\x00v\x05\x1a\x005Rb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xe2zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01', + # b'\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xa0\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xa0\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01', + # b"b\x1bR\x00U\x00\x00\x16R\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xef\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07$\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08>\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5", + # b'\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xacL\x00v\x05\x1a\x005Sb\x00b\x00rc\x02\x01q\x01c\xc3 \x00\x1b\x1b\x1b\x1b\x1a\x00p\xed\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005Tb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcp\x0b\t\x01', + # b'ISK\x00\x03\xcf!\x02\x01\x01cj\x94\x00v\x05\x1a\x005Ub\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xe3zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07', + # b'\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xb0\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xb0\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16?\x01w\x07\x01\x00$\x07', + # b"\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xe4\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07%\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x085\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A", + # b'\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01cM\x04\x00v\x05\x1a\x005Vb\x00b\x00rc\x02\x01q\x01c\x07+\x00\x1b\x1b\x1b\x1b\x1a\x00q,\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005Wb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcq\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\x95\x8b\x00v\x05', + # b'\x1a\x005Xb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xe4zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xff', + # b'Y\x00\x00\x00\x00\x17\xb3j\xc0\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xc0\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16Q\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xe9\x01w\x07', + # b"\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07&\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08B\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi", + # b'\x01\x01\x01c\x82\xf1\x00v\x05\x1a\x005Yb\x00b\x00rc\x02\x01q\x01cK7\x00\x1b\x1b\x1b\x1b\x1a\x003^\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005Zb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcr\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\x1d\xa4\x00v\x05\x1a\x005[b\x00b\x00rc\x07\x01w\x01\x0b\t', + # b'\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xe5zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xd0\x01w\x07\x01\x00\x01\x08', + # b'\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xd0\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x16%\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xd7\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07', + # b"!\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08,\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xf3\x97\x00v\x05\x1a\x005\\b\x00b", + # b'\x00rc\x02\x01q\x01c\x8f<\x00\x1b\x1b\x1b\x1b\x1a\x00\xfc\xab\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005]b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcs\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c.8\x00v\x05\x1a\x005^b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xff', + # b'rb\x01e\x12\xda\x9b\xe6zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xe0\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j', + # b'\xe0\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x15\xb4\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xa7\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xf5\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00', + # b"U\x00\x00\x08\x17\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01cJ\x84\x00v\x05\x1a\x005_b\x00b\x00rc\x02\x01q\x01c<\xc2\x00\x1b\x1b\x1b\x1b\x1a", + # b'\x00\xe7\xf4\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005`b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbct\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c)\xc5\x00v\x05\x1a\x005ab\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xe7zw\x07\x81\x81\xc7\x82\x03', + # b'\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xf0\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3j\xf0\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xff', + # b'Y\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x15\x85\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\x9c\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xde\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x08\x0e\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01', + # b"\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xaa\x91\x00v\x05\x1a\x005bb\x00b\x00rc\x02\x01q\x01c\x9d\xe6\x00\x1b\x1b\x1b\x1b\x1a\x00\xb11\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005", + # b'cb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcu\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xd6\xda\x00v\x05\x1a\x005db\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xe8zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00', + # b'\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x00\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x00\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07', + # b"\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x15O\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\x8e\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xd6\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\xea\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n", + # b'\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\x12{\x00v\x05\x1a\x005eb\x00b\x00rc\x02\x01q\x01c{F\x00\x1b\x1b\x1b\x1b\x1a\x00\x8e\xa7\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005fb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc', + # b'v\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xd7\xfa\x00v\x05\x1a\x005gb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xe9zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!', + # b'\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x10\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x10\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x14\xf8\x01w\x07', + # b"\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06c\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xc1\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\xd3\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6", + # b'TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\x81e\x00v\x05\x1a\x005hb\x00b\x00rc\x02\x01q\x01c\x15\xf1\x00\x1b\x1b\x1b\x1b\x1a\x00\xe1p\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005ib\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcw\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01cm', + # b'i\x00v\x05\x1a\x005jb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xeazw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01', + # b'b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x1f\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x1f\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x14\xff\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06', + # b"\x8b\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xd0\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\xc0\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7", + # b'\xaaixi\x01\x01\x01c8Y\x00v\x05\x1a\x005kb\x00b\x00rc\x02\x01q\x01c\xa6\x0f\x00\x1b\x1b\x1b\x1b\x1a\x00\xc86\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005lb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcx\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xb6U\x00v\x05\x1a\x005mb\x00b\x00rc\x07\x01', + # b'w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xebzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k.\x01w\x07', + # b'\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k.\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x14D\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x062\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00', + # b"U\x00\x00\x06\x87\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x8f\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c 2\x00v\x05\x1a\x005", + # b'nb\x00b\x00rc\x02\x01q\x01cb\x04\x00\x1b\x1b\x1b\x1b\x1a\x00>\x81\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005ob\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcy\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01cIJ\x00v\x05\x1a\x005pb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00', + # b'b\n\xff\xffrb\x01e\x12\xda\x9b\xeczw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k=\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00', + # b'\x00\x17\xb3k=\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x13\xf1\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\n\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06r\x01w\x07\x01\x00L\x07\x00\xff\x01\x01', + # b"b\x1bR\x00U\x00\x00\x07p\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\\D\x00v\x05\x1a\x005qb\x00b\x00rc\x02\x01q\x01cki\x00\x1b", + # b'\x1b\x1b\x1b\x1a\x00\xb6\xff\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005rb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbcz\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xd3z\x00v\x05\x1a\x005sb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xedzw\x07\x81', + # b'\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3kL\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3kL\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01', + # b'b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x13\x8a\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\xef\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06Y\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07@\x01w\x07\x81\x81\xc7\x82', + # b"\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01cz]\x00v\x05\x1a\x005tb\x00b\x00rc\x02\x01q\x01c\xafb\x00\x1b\x1b\x1b\x1b\x1a\x00\xe2\xf8\x1b\x1b\x1b\x1b\x01\x01\x01\x01v", + # b'\x05\x1a\x005ub\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc{\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xe0\xe6\x00v\x05\x1a\x005vb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xeezw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07', + # b'\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3kZ\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3kZ\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07', + # b"\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x12\xde\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\xab\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06!\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x11\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81", + # b'\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xed\xa7\x00v\x05\x1a\x005wb\x00b\x00rc\x02\x01q\x01c\x1c\x9c\x00\x1b\x1b\x1b\x1b\x1a\x00<\xc2\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005xb\x00b\x00rc\x01\x01v\x01\x01', + # b'\x05\x08\xaa\xbc|\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xd1:\x00v\x05\x1a\x005yb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xefzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK', + # b'\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3kh\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3kh\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x12', + # b"\xce\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\xbc\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\x0b\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x07\x06\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02", + # b'\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\x89\x9d\x00v\x05\x1a\x005zb\x00b\x00rc\x02\x01q\x01cr+\x00\x1b\x1b\x1b\x1b\x1a\x00m\x1b\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005{b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc}\x0b\t\x01ISK\x00\x03\xcf!\x02', + # b'\x01\x01c.%\x00v\x05\x1a\x005|b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xf1zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00', + # b'\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3kv\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3kv\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x12\x08\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00', + # b"U\x00\x00\x05j\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\xd6\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xc6\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1", + # b'\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xb3\xb3\x00v\x05\x1a\x005}b\x00b\x00rc\x02\x01q\x01c\x94\x8b\x00\x1b\x1b\x1b\x1b\x1a\x00\xd2\x98\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005~b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc~\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c/\x05\x00v\x05\x1a\x005\x7fb\x00b\x00', + # b'rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xf2zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k', + # b'\x83\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x83\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x11\xc1\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05V\x01w\x07\x01\x008\x07\x00\xff\x01\x01', + # b"b\x1bR\x00U\x00\x00\x05\xb2\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\xb8\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xa6\xab\x00v", + # b'\x05\x1a\x005\x80b\x00b\x00rc\x02\x01q\x01c+\xf0\x00\x1b\x1b\x1b\x1b\x1a\x00\xa1\xcc\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x81b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x7f\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c{3\x00v\x05\x1a\x005\x82b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!', + # b'\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xf3zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x90\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xff', + # b'Y\x00\x00\x00\x00\x17\xb3k\x90\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x116\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05#\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\x88\x01w\x07\x01\x00L\x07', + # b"\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\x8a\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01cR\xa4\x00v\x05\x1a\x005\x83b\x00b\x00rc\x02\x01q\x01c", + # b'\x98\x0e\x00\x1b\x1b\x1b\x1b\x1a\x00\xbe\xfe\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x84b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x80\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01cy\xb9\x00v\x05\x1a\x005\x85b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xf4', + # b'zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x9d\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\x9d\x01w\x07\x01\x00\x01\x08', + # b'\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x10\xd2\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\x03\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05c\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06j\x01w\x07', + # b"\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c}u\x00v\x05\x1a\x005\x86b\x00b\x00rc\x02\x01q\x01c\\\x05\x00\x1b\x1b\x1b\x1b\x1a\x00*f\x1b\x1b\x1b\x1b\x01", + # b'\x01\x01\x01v\x05\x1a\x005\x87b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x81\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\x86\xa6\x00v\x05\x1a\x005\x88b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xf5zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04IS', + # b'K\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xa9\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xa9\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00', + # b"\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x10S\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\xde\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x055\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06?\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'", + # b'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xaf\xc0\x00v\x05\x1a\x005\x89b\x00b\x00rc\x02\x01q\x01c\x10\x19\x00\x1b\x1b\x1b\x1b\x1a\x00\x07\x1b\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x8ab\x00b\x00rc\x01', + # b'\x01v\x01\x01\x05\x08\xaa\xbc\x82\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\x0e\x89\x00v\x05\x1a\x005\x8bb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xf6zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t', + # b'\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xb5\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xb5\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00', + # b"U\x00\x00\x0f\xdc\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\x9e\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\x15\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06(\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf", + # b'!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\x8a\x16\x00v\x05\x1a\x005\x8cb\x00b\x00rc\x02\x01q\x01c\xd4\x12\x00\x1b\x1b\x1b\x1b\x1a\x00\xa7_\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x8db\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x83\x0b\t\x01ISK\x00', + # b'\x03\xcf!\x02\x01\x01c=\x15\x00v\x05\x1a\x005\x8eb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xf7zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08', + # b'\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xc1\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xc1\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0f\xa9\x01w\x07\x01\x00$\x07\x00\xff\x01\x01', + # b"b\x1bR\x00U\x00\x00\x04\xa2\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\xee\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x06\x19\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89", + # b'\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xb6?\x00v\x05\x1a\x005\x8fb\x00b\x00rc\x02\x01q\x01cg\xec\x00\x1b\x1b\x1b\x1b\x1a\x00\xa8Y\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x90b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x84\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\x1e\xd6\x00v\x05\x1a\x005\x91', + # b'b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xf8zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00', + # b'\x00\x17\xb3k\xcd\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xcd\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0fN\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\x8b\x01w\x07\x01\x008\x07', + # b"\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\xce\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\xf4\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c", + # b'\xfc\x0c\x00v\x05\x1a\x005\x92b\x00b\x00rc\x02\x01q\x01cL*\x00\x1b\x1b\x1b\x1b\x1a\x00\xf3\xd6\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x93b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x85\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xe1\xc9\x00v\x05\x1a\x005\x94b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK', + # b'\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xf9zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xd8\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01', + # b'b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xd8\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0f"\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\x83\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\xc5\x01w\x07', + # b"\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\xd9\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01cOS\x00v\x05\x1a\x005\x95b\x00b\x00rc\x02", + # b'\x01q\x01c\xaa\x8a\x00\x1b\x1b\x1b\x1b\x1a\x00\x10\xc6\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x96b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x86\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xe0\xe9\x00v\x05\x1a\x005\x97b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e', + # b'\x12\xda\x9b\xfazw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xe3\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xe3\x01w\x07', + # b'\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0e\xd7\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04o\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\xa8\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05', + # b"\xbf\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01cv\xcd\x00v\x05\x1a\x005\x98b\x00b\x00rc\x02\x01q\x01c\xc4=\x00\x1b\x1b\x1b\x1b\x1a\x00\xc3d\x1b", + # b'\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x99b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x87\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01cZz\x00v\x05\x1a\x005\x9ab\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xfbzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01', + # b'\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xee\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xee\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00', + # b'\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0e;\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04:\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04k\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\x95\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02', + # b"?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xda \x00v\x05\x1a\x005\x9bb\x00b\x00rc\x02\x01q\x01cw\xc3\x00\x1b\x1b\x1b\x1b\x1a\x004\xc0\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x9cb\x00b", + # b'\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x88\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\x81F\x00v\x05\x1a\x005\x9db\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xfczw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01', + # b'\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xf9\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3k\xf9\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01', + # b"b\x1bR\x00U\x00\x00\x0e\x1f\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x046\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04]\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\x8b\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5", + # b'\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xcdp\x00v\x05\x1a\x005\x9eb\x00b\x00rc\x02\x01q\x01c\xb3\xc8\x00\x1b\x1b\x1b\x1b\x1a\x00\x85\xc3\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\x9fb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x89\x0b\t\x01', + # b'ISK\x00\x03\xcf!\x02\x01\x01c~Y\x00v\x05\x1a\x005\xa0b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xfdzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07', + # b'\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l\x03\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l\x03\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\r\xd1\x01w\x07\x01\x00$\x07', + # b"\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\x10\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04O\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05q\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A", + # b'\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xb0\xbf\x00v\x05\x1a\x005\xa1b\x00b\x00rc\x02\x01q\x01c0G\x00\x1b\x1b\x1b\x1b\x1a\x00j\xfd\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xa2b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x8a\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xc0W\x00v\x05', + # b'\x1a\x005\xa3b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xfezw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xff', + # b'Y\x00\x00\x00\x00\x17\xb3l\r\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l\r\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\r\x1a\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\xd5\x01w\x07', + # b"\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\x0f\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x055\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi", + # b'\x01\x01\x01c/\x99\x00v\x05\x1a\x005\xa4b\x00b\x00rc\x02\x01q\x01c\xf4L\x00\x1b\x1b\x1b\x1b\x1a\x00\x91$\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xa5b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x8b\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xf3\xcb\x00v\x05\x1a\x005\xa6b\x00b\x00rc\x07\x01w\x01\x0b\t', + # b'\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9b\xffzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l\x17\x01w\x07\x01\x00\x01\x08', + # b'\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l\x17\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0c\xd2\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\xb1\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04', + # b"\x05\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x05\x1b\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c=\xa3\x00v\x05\x1a\x005\xa7b\x00b", + # b'\x00rc\x02\x01q\x01cG\xb2\x00\x1b\x1b\x1b\x1b\x1a\x00\x1f\xca\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xa8b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x8c\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xc2\x17\x00v\x05\x1a\x005\xa9b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xff', + # b'rb\x01e\x12\xda\x9c\x00zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l!\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l', + # b'!\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0c\x9b\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\xa4\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\xec\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00', + # b"U\x00\x00\x05\n\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xb8\xa0\x00v\x05\x1a\x005\xaab\x00b\x00rc\x02\x01q\x01c)\x05\x00\x1b\x1b\x1b\x1b\x1a", + # b'\x00A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xcc\xb3\x00v\x05\x1a\x005\xadb\x00b\x00rc\x02\x01q\x01c\xcf\xa5\x00\x1b\x1b\x1b\x1b\x1a\x00#\xd1\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005", + # b'\xaeb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x8e\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c<(\x00v\x05\x1a\x005\xafb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9c\x02zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00', + # b'\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l3\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l3\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07', + # b"\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0b\xe3\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\x81\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\xa6\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\xbb\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n", + # b'\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c3\x1a\x00v\x05\x1a\x005\xb0b\x00b\x00rc\x02\x01q\x01c\xe4c\x00\x1b\x1b\x1b\x1b\x1a\x00\xc0*\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xb1b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x8f\x0b\t\x01ISK\x00', + # b'\x03\xcf!\x02\x01\x01c\x94\xa4\x00v\x05\x1a\x005\xb2b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9c\x03zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xff', + # b'e\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l<\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l<\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0b\x8d\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03^\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00', + # b"\x03\x8c\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04\xa3\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\x85.\x00v\x05\x1a\x005\xb3b\x00", + # b'b\x00rc\x02\x01q\x01cW\x9d\x00\x1b\x1b\x1b\x1b\x1a\x00\xab\xc6\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xb4b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x90\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\x98N\x00v\x05\x1a\x005\xb5b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff', + # b'\xffrb\x01e\x12\xda\x9c\x04zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3lE\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3', + # b'lE\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x0b\x07\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x036\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03`\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR', + # b"\x00U\x00\x00\x04p\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xad\xaa\x00v\x05\x1a\x005\xb6b\x00b\x00rc\x02\x01q\x01c\x93\x96\x00\x1b\x1b\x1b\x1b", + # b'\x1a\x00\xa5\xf4\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xb7b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x91\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01cgQ\x00v\x05\x1a\x005\xb8b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9c\x06zw\x07\x81\x81\xc7\x82', + # b'\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3lM\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3lM\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR', + # b'\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\n\xda\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x037\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03H\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04Z\x01w\x07\x81\x81\xc7\x82\x05\xff\x01', + # b"\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xe5\xf1\x00v\x05\x1a\x005\xb9b\x00b\x00rc\x02\x01q\x01c\xdf\x8a\x00\x1b\x1b\x1b\x1b\x1a\x002\x9c\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x00", + # b'5\xbab\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x92\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xef~\x00v\x05\x1a\x005\xbbb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9c\x07zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00', + # b'\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3lU\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3lU\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10', + # b"\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\n\xcf\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x034\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03I\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04S\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c", + # b'\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xe3"\x00v\x05\x1a\x005\xbcb\x00b\x00rc\x02\x01q\x01c\x1b\x81\x00\x1b\x1b\x1b\x1b\x1a\x00\xbd\xb0\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xbdb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa', + # b'\xbc\x93\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xdc\xe2\x00v\x05\x1a\x005\xbeb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9c\x08zw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf', + # b'!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l]\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l]\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\n\x93\x01w', + # b'\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03"\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03-\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04D\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef\'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01', + # b'\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xedQ\x00v\x05\x1a\x005\xbfb\x00b\x00rc\x02\x01q\x01c\xa8\x7f\x00\x1b\x1b\x1b\x1b\x1a\x00\xd8\x0e\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xc0b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x94\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c', + # b'\x93c\x00v\x05\x1a\x005\xc1b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9c\tzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82', + # b'\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3le\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3le\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\n\x91\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00', + # b"\x03\x1a\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x037\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04?\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb", + # b'\xf7\xaaixi\x01\x01\x01c\xdc;\x00v\x05\x1a\x005\xc2b\x00b\x00rc\x02\x01q\x01c\x0c\x96\x00\x1b\x1b\x1b\x1b\x1a\x00\xe9\xb7\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xc3b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x95\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01cl|\x00v\x05\x1a\x005\xc4b\x00b\x00rc\x07', + # b'\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9c\nzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3lm\x01w', + # b'\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3lm\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\n^\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\x0f\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR', + # b"\x00U\x00\x00\x03 \x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04-\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01cm\xf9\x00v\x05\x1a\x00", + # b'5\xc5b\x00b\x00rc\x02\x01q\x01c\xea6\x00\x1b\x1b\x1b\x1b\x1a\x00\x8db\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xc6b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x96\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01cm\\\x00v\x05\x1a\x005\xc7b\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01', + # b'\x00b\n\xff\xffrb\x01e\x12\xda\x9c\x0bzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3lu\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00', + # b'\x00\x00\x17\xb3lu\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\nN\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\x08\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\x15\x01w\x07\x01\x00L\x07\x00\xff\x01', + # b"\x01b\x1bR\x00U\x00\x00\x04/\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xcbt\x00v\x05\x1a\x005\xc8b\x00b\x00rc\x02\x01q\x01c\x84\x81\x00", + # b'\x1b\x1b\x1b\x1b\x1a\x00\x94{\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x1a\x005\xc9b\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x97\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\xd7\xcf\x00v\x05\x1a\x005\xcab\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9c\x0czw\x07', + # b'\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l}\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l}\x01w\x07\x01\x00\x01\x08\x02\xff\x01', + # b'\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\nH\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\x00\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\x1a\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x04(\x01w\x07\x81\x81\xc7', + # b"\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc\x81\xbc\x0b\x8c\n\xfd\x99\xb8\xf5\x9dX\xddf!d.\x02\xdd\xb6\x01\xc6TF>A\x0e\xc7\xef\x89\xa5\x8b\xf3\xd1\x93-\xbb\xf7\xaaixi\x01\x01\x01c\xb7\x13\x00v\x05\x1a\x005\xcbb\x00b\x00rc\x02\x01q\x01c7\x7f\x00\x1b\x1b\x1b\x1b\x1a\x00\xda\xee\x1b\x1b\x1b\x1b\x01\x01\x01\x01", + # b'v\x05\x1a\x005\xccb\x00b\x00rc\x01\x01v\x01\x01\x05\x08\xaa\xbc\x98\x0b\t\x01ISK\x00\x03\xcf!\x02\x01\x01c\x0c\xf3\x00v\x05\x1a\x005\xcdb\x00b\x00rc\x07\x01w\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x07\x01\x00b\n\xff\xffrb\x01e\x12\xda\x9c\rzw\x07\x81\x81\xc7\x82\x03\xff\x01\x01\x01\x01\x04ISK\x01w', + # b'\x07\x01\x00\x00\x00\t\xff\x01\x01\x01\x01\x0b\t\x01ISK\x00\x03\xcf!\x02\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x00\x01\x82\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l\x85\x01w\x07\x01\x00\x01\x08\x01\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x17\xb3l\x85\x01w\x07\x01\x00\x01\x08\x02\xff\x01\x01b\x1eR\xffY\x00\x00\x00\x00\x00\x00\x00\x00\x01w', + # b"\x07\x01\x00\x10\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\n6\x01w\x07\x01\x00$\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x02\xf1\x01w\x07\x01\x008\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x03\x12\x01w\x07\x01\x00L\x07\x00\xff\x01\x01b\x1bR\x00U\x00\x00\x042\x01w\x07\x81\x81\xc7\x82\x05\xff\x01\x01\x01\x01\x83\x02?\n\xef'(\x18\xbc", +] + +HAUS = [ + b'\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xbab\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x97\xfa\x01c\x05g\x00v\x05\x00\xe2\xc5\xbbb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n', + b'\xff\xffrb\x01e\x00K\x97\xfavw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xae\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00', + b'W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xed!\x00v\x05\x00\xe2\xc5\xbcb\x00b\x00re\x00\x00\x02\x01q\x01c\xd3\x1d\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x17\xeb\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xbdb\x00b\x00re\x00\x00\x01\x01', + b'v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x97\xfb\x01c\x0fy\x00v\x05\x00\xe2\xc5\xbeb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x97\xfbvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZP', + b'A\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xaf\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z', + b'\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cv\xcd\x00v\x05\x00\xe2\xc5\xbfb\x00b\x00re\x00\x00\x02\x01q\x01c$\x13\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xdcT\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xc0b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01', + b'\x92\x0c\xd1rb\x01e\x00K\x97\xfc\x01c"+\x00v\x05\x00\xe2\xc5\xc1b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x97\xfcvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c', + b'\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xb1\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xfes\x00v\x05\x00\xe2\xc5\xc2', + b'b\x00b\x00re\x00\x00\x02\x01q\x01ct\xa1\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02r?\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xc3b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x97\xfd\x01c[_\x00v\x05\x00\xe2\xc5\xc4b\x00', + b'b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x97\xfdvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00', + b'\x00\x00J\xb6\xb3\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xd5\x1f\x00v\x05\x00\xe2\xc5\xc5b\x00b\x00re\x00\x00\x02\x01q\x01c7\xb9\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02', + b'r\xa9\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xc6b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x97\xfe\x01c\xd0\xc3\x00v\x05\x00\xe2\xc5\xc7b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00', + b'b\n\xff\xffrb\x01e\x00K\x97\xfevw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xb4\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00', + b'\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x84O\x00v\x05\x00\xe2\xc5\xc8b\x00b\x00re\x00\x00\x02\x01q\x01cF\x87\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x90~\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xc9b\x00b\x00re\x00\x00', + b'\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x97\xff\x01c<\t\x00v\x05\x00\xe2\xc5\xcab\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x97\xffvw\x07\x01\x00`2\x01\x01\x01\x01\x01', + b'\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xb6\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07', + b'\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x886\x00v\x05\x00\xe2\xc5\xcbb\x00b\x00re\x00\x00\x02\x01q\x01c\xb1\x89\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02(\xb4\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xccb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01Z', + b'PA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x00\x01c\xd8\n\x00v\x05\x00\xe2\xc5\xcdb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x00vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA', + b'\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xb8\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xb5s\x00v\x05', + b'\x00\xe2\xc5\xceb\x00b\x00re\x00\x00\x02\x01q\x01c\xa8\x9a\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02y|\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xcfb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x01\x01c\xa1~\x00v\x05\x00\xe2', + b'\xc5\xd0b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x01vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xff', + b'i\x00\x00\x00\x00\x00J\xb6\xb9\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cG\xa0\x00v\x05\x00\xe2\xc5\xd1b\x00b\x00re\x00\x00\x02\x01q\x01cS\xf5\x00\x00\x00\x1b\x1b', + b'\x1b\x1b\x1a\x02\xa5S\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xd2b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x02\x01c\x11\x97\x00v\x05\x00\xe2\xc5\xd3b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c', + b'\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x02vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xbb\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xff', + b'i\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xd7y\x00v\x05\x00\xe2\xc5\xd4b\x00b\x00re\x00\x00\x02\x01q\x01cJ\xe6\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x1b\x8b\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xd5b\x00b\x00', + b're\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x03\x01c\x1b\x89\x00v\x05\x00\xe2\xc5\xd6b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x03vw\x07\x01\x00`2\x01', + b'\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xbc\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x030', + b'1\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c=\x9c\x00v\x05\x00\xe2\xc5\xd7b\x00b\x00re\x00\x00\x02\x01q\x01c\xbd\xe8\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xa1\xd9\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xd8b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04', + b'\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x04\x01c\x16\xa6\x00v\x05\x00\xe2\xc5\xd9b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x04vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n', + b'\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xbe\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xca', + b'&\x00v\x05\x00\xe2\xc5\xdab\x00b\x00re\x00\x00\x02\x01q\x01c\xcc\xd6\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x1a\xad\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xdbb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x05\x01co\xd2\x00', + b'v\x05\x00\xe2\xc5\xdcb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x05vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01', + b'b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xc0\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cd\x0e\x00v\x05\x00\xe2\xc5\xddb\x00b\x00re\x00\x00\x02\x01q\x01c\x8f\xce\x00', + b'\x00\x00\x1b\x1b\x1b\x1b\x1a\x02M\x87\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xdeb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x06\x01c\xe4N\x00v\x05\x00\xe2\xc5\xdfb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA', + b'\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x06vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xc1\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01', + # b'b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cDW\x00v\x05\x00\xe2\xc5\xe0b\x00b\x00re\x00\x00\x02\x01q\x01c\x8e\x1f\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02 \xd9\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xe1', + # b'b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x07\x01c~n\x00v\x05\x00\xe2\xc5\xe2b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x07vw\x07\x01', + # b'\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xc3\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01', + # b'\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x9aP\x00v\x05\x00\xe2\xc5\xe3b\x00b\x00re\x00\x00\x02\x01q\x01cy\x11\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02!\x1b\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xe4b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05', + # b'\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x08\x01cU[\x00v\x05\x00\xe2\xc5\xe5b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x08vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01', + # b'\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xc5\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01', + # b'\x01\x01c\xbc\xf0\x00v\x05\x00\xe2\xc5\xe6b\x00b\x00re\x00\x00\x02\x01q\x01c`\x02\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x95\x11\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xe7b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\t\x01', + # b'c,/\x00v\x05\x00\xe2\xc5\xe8b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\tvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00', + # b'\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xc6\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c \xf6\x00v\x05\x00\xe2\xc5\xe9b\x00b\x00re\x00\x00\x02\x01q\x01', + # b'cK7\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02.\x17\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xeab\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\n\x01cAg\x00v\x05\x00\xe2\xc5\xebb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n', + # b'\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\nvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xc8\x01w\x07\x01\x00\x02\x08\x00\xff\x01', + # b'\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cR=\x00v\x05\x00\xe2\xc5\xecb\x00b\x00re\x00\x00\x02\x01q\x01cR$\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x19\xb3\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5', + # b'\xedb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x0b\x01cKy\x00v\x05\x00\xe2\xc5\xeeb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x0bvw\x07', + # b'\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xca\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01', + # b'\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cyQ\x00v\x05\x00\xe2\xc5\xefb\x00b\x00re\x00\x00\x02\x01q\x01c\xa5*\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02b\xfe\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xf0b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1', + # b'\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x0c\x01c\x9b\xf7\x00v\x05\x00\xe2\xc5\xf1b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x0cvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01', + # b'\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xcb\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d', + # b'\x01\x01\x01cpA\x00v\x05\x00\xe2\xc5\xf2b\x00b\x00re\x00\x00\x02\x01q\x01c\x04N\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02[\xa4\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xf3b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\r', + # b'\x01c\xe2\x83\x00v\x05\x00\xe2\xc5\xf4b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\rvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe', + # b'\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xcd\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\n\xdb\x00v\x05\x00\xe2\xc5\xf5b\x00b\x00re\x00\x00\x02\x01q', + # b'\x01cGV\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x05\x84\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xf6b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x0e\x01ci\x1f\x00v\x05\x00\xe2\xc5\xf7b\x00b\x00re\x00\x00\x07\x01w\x01\x0b', + # b'\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x0evw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xce\x01w\x07\x01\x00\x02', + # b'\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\n}\x00v\x05\x00\xe2\xc5\xf8b\x00b\x00re\x00\x00\x02\x01q\x01c6h\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xb9\xe5\x1b\x1b\x1b\x1b\x01\x01\x01\x01v', + # b'\x05\x00\xe2\xc5\xf9b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x0f\x01c\x85\xd5\x00v\x05\x00\xe2\xc5\xfab\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98', + # b'\x0fvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xd0\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00', + # b'\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x93\xd7\x00v\x05\x00\xe2\xc5\xfbb\x00b\x00re\x00\x00\x02\x01q\x01c\xc1f\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xa84\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xfcb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00', + # b'\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x10\x01c?u\x00v\x05\x00\xe2\xc5\xfdb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x10vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`', + # b'\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xd2\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05', + # b'rI\xa0\x1d\x01\x01\x01c\xbbz\x00v\x05\x00\xe2\xc5\xfeb\x00b\x00re\x00\x00\x02\x01q\x01c\xd8u\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xc3\xcc\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc5\xffb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e', + # b'\x00K\x98\x11\x01cF\x01\x00v\x05\x00\xe2\xc6\x00b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x11vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01', + # b'\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xd3\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cS\x11\x00v\x05\x00\xe2\xc6\x01b\x00b\x00re\x00', + # b'\x00\x02\x01q\x01cN\x89\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xebx\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\x02b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x12\x01cX\xa6\x00v\x05\x00\xe2\xc6\x03b\x00b\x00re\x00\x00\x07', + # b'\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x12vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xd5\x01w', + # b'\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x92>\x00v\x05\x00\xe2\xc6\x04b\x00b\x00re\x00\x00\x02\x01q\x01cW\x9a\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02m\xf4\x1b\x1b\x1b\x1b\x01', + # b'\x01\x01\x01v\x05\x00\xe2\xc6\x05b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x13\x01cR\xb8\x00v\x05\x00\xe2\xc6\x06b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01', + # b'e\x00K\x98\x13vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xd7\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w', + # b'\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xb9R\x00v\x05\x00\xe2\xc6\x07b\x00b\x00re\x00\x00\x02\x01q\x01c\xa0\x94\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x16\xb9\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\x08b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01', + # b'ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x14\x01c_\x97\x00v\x05\x00\xe2\xc6\tb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x14vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w', + # b'\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xd8\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01', + # b'\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c<\x85\x00v\x05\x00\xe2\xc6\nb\x00b\x00re\x00\x00\x02\x01q\x01c\xd1\xaa\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xc1\xb6\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\x0bb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1', + # b'rb\x01e\x00K\x98\x15\x01c&\xe3\x00v\x05\x00\xe2\xc6\x0cb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x15vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w', + # b'\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xda\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x17\xe9\x00v\x05\x00\xe2\xc6\rb\x00b', + # b'\x00re\x00\x00\x02\x01q\x01c\x92\xb2\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xc1 \x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\x0eb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x16\x01c\xad\x7f\x00v\x05\x00\xe2\xc6\x0fb\x00b\x00r', + # b'e\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x16vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J', + # b'\xb6\xdc\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xd6\xc6\x00v\x05\x00\xe2\xc6\x10b\x00b\x00re\x00\x00\x02\x01q\x01c3\xd6\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x19\x85\x1b', + # b'\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\x11b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x17\x01c\x9c\x14\x00v\x05\x00\xe2\xc6\x12b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff', + # b'\xffrb\x01e\x00K\x98\x17vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xdd\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W', + # b'\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c$\x15\x00v\x05\x00\xe2\xc6\x13b\x00b\x00re\x00\x00\x02\x01q\x01c\xc4\xd8\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x86\xba\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\x14b\x00b\x00re\x00\x00\x01\x01v', + # b'\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x18\x01c\xb7!\x00v\x05\x00\xe2\xc6\x15b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x18vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04Z', + # b'PA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xdf\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`', + # b'Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cSC\x00v\x05\x00\xe2\xc6\x16b\x00b\x00re\x00\x00\x02\x01q\x01c\xdd\xcb\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02l\x06\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\x17b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00', + # b'\x01\x92\x0c\xd1rb\x01e\x00K\x98\x19\x01c\xceU\x00v\x05\x00\xe2\xc6\x18b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x19vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92', + # b'\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xe0\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xa5\x1c\x00v\x05\x00\xe2\xc6', + # b'\x19b\x00b\x00re\x00\x00\x02\x01q\x01c\xf6\xfe\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xdb\x81\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\x1ab\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x1a\x01c\xa3\x1d\x00v\x05\x00\xe2\xc6\x1bb', + # b'\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x1avw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00', + # b'\x00\x00\x00J\xb6\xe2\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c5\xc5\x00v\x05\x00\xe2\xc6\x1cb\x00b\x00re\x00\x00\x02\x01q\x01c\xef\xed\x00\x00\x00\x1b\x1b\x1b\x1b\x1a', + # b'\x02\x1f\xf7\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\x1db\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x1b\x01c\xa9\x03\x00v\x05\x00\xe2\xc6\x1eb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01', + # b'\x00b\n\xff\xffrb\x01e\x00K\x98\x1bvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xe4\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00', + # b'\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cO_\x00v\x05\x00\xe2\xc6\x1fb\x00b\x00re\x00\x00\x02\x01q\x01c\x18\xe3\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02:\x0c\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6 b\x00b\x00re\x00\x00', + # b'\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x1c\x01c\xd2\xc6\x00v\x05\x00\xe2\xc6!b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x1cvw\x07\x01\x00`2\x01\x01\x01\x01\x01', + # b'\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xe5\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07', + # b'\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xda\x1b\x00v\x05\x00\xe2\xc6"b\x00b\x00re\x00\x00\x02\x01q\x01c\x192\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02v\xe6\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6#b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01Z', + # b'PA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x1d\x01c\xab\xb2\x00v\x05\x00\xe2\xc6$b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x1dvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA', + # b'\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xe7\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xf1w\x00v\x05', + # b'\x00\xe2\xc6%b\x00b\x00re\x00\x00\x02\x01q\x01cZ*\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02vp\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6&b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x1e\x01c .\x00v\x05\x00\xe2', + # b"\xc6'b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x1evw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xff", + # b'i\x00\x00\x00\x00\x00J\xb6\xe9\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x83\xbc\x00v\x05\x00\xe2\xc6(b\x00b\x00re\x00\x00\x02\x01q\x01c+\x14\x00\x00\x00\x1b\x1b', + # b'\x1b\x1b\x1a\x02\xa6j\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6)b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98\x1f\x01c\xcc\xe4\x00v\x05\x00\xe2\xc6*b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c', + # b'\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98\x1fvw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xea\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xff', + # b'i\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x1f\xba\x00v\x05\x00\xe2\xc6+b\x00b\x00re\x00\x00\x02\x01q\x01c\xdc\x1a\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x81\t\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6,b\x00b\x00', + # b're\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98 \x01cEg\x00v\x05\x00\xe2\xc6-b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98 vw\x07\x01\x00`2\x01', + # b'\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xec\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x030', + # b'1\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xc9\x1f\x00v\x05\x00\xe2\xc6.b\x00b\x00re\x00\x00\x02\x01q\x01c\xc5\t\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xb6\xce\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6/b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04', + # b'\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98!\x01c<\x13\x00v\x05\x00\xe2\xc60b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98!vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n', + # b'\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xee\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x8b', + # b'L\x00v\x05\x00\xe2\xc61b\x00b\x00re\x00\x00\x02\x01q\x01c>f\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xda\x13\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc62b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98"\x01c\x8c\xfa\x00', + # b'v\x05\x00\xe2\xc63b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98"vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01', + # b"b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xef\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xab\x15\x00v\x05\x00\xe2\xc64b\x00b\x00re\x00\x00\x02\x01q\x01c'u\x00", + # b'\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xd49\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc65b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98#\x01c\x86\xe4\x00v\x05\x00\xe2\xc66b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA', + # b'\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98#vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xf1\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01', + # b'b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x15\xaa\x00v\x05\x00\xe2\xc67b\x00b\x00re\x00\x00\x02\x01q\x01c\xd0{\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x06o\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc68', + # b'b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98$\x01c\x8b\xcb\x00v\x05\x00\xe2\xc69b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98$vw\x07\x01', + # b'\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xf2\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01', + # b'\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01cro\x00v\x05\x00\xe2\xc6:b\x00b\x00re\x00\x00\x02\x01q\x01c\xa1E\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02"\xb2\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6;b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05', + # b'\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98%\x01c\xf2\xbf\x00v\x05\x00\xe2\xc6b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98&\x01', + # b'cy#\x00v\x05\x00\xe2\xc6?b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98&vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00', + # b'\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xf6\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x98,\x00v\x05\x00\xe2\xc6@b\x00b\x00re\x00\x00\x02\x01q\x01', + # b"c\xb2\xef\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x06H\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6Ab\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98'\x01c\xb5\x94\x00v\x05\x00\xe2\xc6Bb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n", + # b"\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98'vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xf7\x01w\x07\x01\x00\x02\x08", + # b'\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xce\x02\x00v\x05\x00\xe2\xc6Cb\x00b\x00re\x00\x00\x02\x01q\x01cE\xe1\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xebf\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05', + # b'\x00\xe2\xc6Db\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98(\x01c\x9e\xa1\x00v\x05\x00\xe2\xc6Eb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98(', + # b'vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xf9\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02', + # b'\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c[F\x00v\x05\x00\xe2\xc6Fb\x00b\x00re\x00\x00\x02\x01q\x01c\\\xf2\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xf2\x08\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6Gb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01', + # b'\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98)\x01c\xe7\xd5\x00v\x05\x00\xe2\xc6Hb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98)vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01', + # b'\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xfb\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05r', + # b'I\xa0\x1d\x01\x01\x01cW?\x00v\x05\x00\xe2\xc6Ib\x00b\x00re\x00\x00\x02\x01q\x01cw\xc7\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xd6\xa7\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6Jb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00', + # b'K\x98*\x01c\x8a\x9d\x00v\x05\x00\xe2\xc6Kb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98*vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08', + # b'\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xfc\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\x06o\x00v\x05\x00\xe2\xc6Lb\x00b\x00re\x00\x00', + # b'\x02\x01q\x01cn\xd4\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xd3\xce\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6Mb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98+\x01c\x80\x83\x00v\x05\x00\xe2\xc6Nb\x00b\x00re\x00\x00\x07\x01', + # b'w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98+vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb6\xfe\x01w\x07', + # b'\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c-\x03\x00v\x05\x00\xe2\xc6Ob\x00b\x00re\x00\x00\x02\x01q\x01c\x99\xda\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xa8\x83\x1b\x1b\x1b\x1b\x01\x01', + # b'\x01\x01v\x05\x00\xe2\xc6Pb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98,\x01cP\r\x00v\x05\x00\xe2\xc6Qb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e', + # b'\x00K\x98,vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb7\x00\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07', + # b'\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c9x\x00v\x05\x00\xe2\xc6Rb\x00b\x00re\x00\x00\x02\x01q\x01c8\xbe\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x17\x81\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6Sb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01Z', + # b'PA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98-\x01c)y\x00v\x05\x00\xe2\xc6Tb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98-vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07', + # b'\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb7\x01\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01', + # b'\x01\x01\x05rI\xa0\x1d\x01\x01\x01c\xa2\x94\x00v\x05\x00\xe2\xc6Ub\x00b\x00re\x00\x00\x02\x01q\x01c{\xa6\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\xa7\xe5\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6Vb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1r', + # b'b\x01e\x00K\x98.\x01c\xa2\xe5\x00v\x05\x00\xe2\xc6Wb\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98.vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07', + # b'\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb7\x03\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01c2M\x00v\x05\x00\xe2\xc6Xb\x00b\x00', + # b're\x00\x00\x02\x01q\x01c\n\x98\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02\x84-\x1b\x1b\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6Yb\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x98/\x01cN/\x00v\x05\x00\xe2\xc6Zb\x00b\x00re', + # b'\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xffrb\x01e\x00K\x98/vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb7', + # b'\x05\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91\xe1\x01w\x07\x01\x00\x00\x02\x00\x00\x01\x01\x01\x01\x0301\x01w\x07\x01\x00`Z\x02\x01\x01\x01\x01\x01\x05rI\xa0\x1d\x01\x01\x01co\xc2\x00v\x05\x00\xe2\xc6[b\x00b\x00re\x00\x00\x02\x01q\x01c\xfd\x96\x00\x00\x00\x1b\x1b\x1b\x1b\x1a\x02bQ\x1b\x1b', + # b'\x1b\x1b\x01\x01\x01\x01v\x05\x00\xe2\xc6\\b\x00b\x00re\x00\x00\x01\x01v\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x05\x01\x02\x03\x04\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1rb\x01e\x00K\x980\x01c\xf4\x8f\x00v\x05\x00\xe2\xc6]b\x00b\x00re\x00\x00\x07\x01w\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x07\x01\x00b\n\xff\xff', + # b'rb\x01e\x00K\x980vw\x07\x01\x00`2\x01\x01\x01\x01\x01\x01\x04ZPA\x01w\x07\x01\x00`\x01\x00\xff\x01\x01\x01\x01\x0b\n\x01ZPA\x00\x01\x92\x0c\xd1\x01w\x07\x01\x00\x01\x08\x00\xffe\x00\x1c\x01\x04\x01b\x1eR\xffi\x00\x00\x00\x00\x00J\xb7\x06\x01w\x07\x01\x00\x02\x08\x00\xff\x01\x01b\x1eR\xffi\x00\x00\x00\x00\x00W\x91' +] + +RHa = b''.join(HAUS) +RHe = b''.join(HEIZUNG) + +RESULT = bytes(RHa) From d5d85e22ae5543df5188dd9ef9702f2fb8a8233c Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Tue, 3 Dec 2024 18:27:33 +0100 Subject: [PATCH 07/34] smartmeter: fix requirements --- smartmeter/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/smartmeter/requirements.txt b/smartmeter/requirements.txt index f6c1a1f57..9fcf19fe7 100644 --- a/smartmeter/requirements.txt +++ b/smartmeter/requirements.txt @@ -1 +1,2 @@ pyserial +smllib From e095c3cb6d65af8227edc48e50cc806008442281 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Wed, 4 Dec 2024 17:28:21 +0100 Subject: [PATCH 08/34] smartmeter: fix typing for Python 3.9 --- smartmeter/sml.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/smartmeter/sml.py b/smartmeter/sml.py index 556bffb0f..df62e4135 100644 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -43,6 +43,7 @@ from smllib.reader import SmlStreamReader from smllib import const as smlConst from threading import Lock +from typing import Union # needed for old frame parser # from .crc import Crc @@ -99,7 +100,7 @@ RESULT = '' -def to_hex(data: int | str | bytes | bytearray, space: bool = True) -> str: +def to_hex(data: Union[int, str, bytes, bytearray], space: bool = True) -> str: """ Returns the hex representation of the given data """ @@ -140,7 +141,7 @@ def _read(sock, length: int) -> bytes: return b'' -def read(sock: serial.Serial | socket.socket, length: int = 0) -> bytes: +def read(sock: Union[serial.Serial, socket.socket], length: int = 0) -> bytes: """ This function reads some bytes from serial or network interface it returns an array of bytes if a timeout occurs or a given end byte is encountered From efb036c74c79665809d29e5b2f39d5fab88ad246 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:13:00 +0100 Subject: [PATCH 09/34] smartmeter: fix and refactor code, improve error handling --- smartmeter/__init__.py | 67 +++-- smartmeter/dlms-sample.txt | 278 -------------------- smartmeter/dlms.py | 519 ++++++++++++++++++++++--------------- smartmeter/dlms_test.py | 0 smartmeter/plugin.yaml | 44 ++-- smartmeter/sml.py | 420 ++++++++++++++++++------------ smartmeter/sml_test.py | 0 7 files changed, 608 insertions(+), 720 deletions(-) mode change 100644 => 100755 smartmeter/__init__.py delete mode 100644 smartmeter/dlms-sample.txt mode change 100644 => 100755 smartmeter/dlms_test.py mode change 100644 => 100755 smartmeter/sml.py mode change 100644 => 100755 smartmeter/sml_test.py diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py old mode 100644 new mode 100755 index 8f6d77eda..a1ff3f5fb --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -65,11 +65,12 @@ ITEM_ATTRS = (OBIS_CODE, OBIS_INDEX, OBIS_PROPERTY, OBIS_VTYPE, OBIS_READOUT) -# obis properties with default (empty) values -PROPS = { - 'value': [], - 'unit': '' -} +# obis properties +PROPS = [ + 'value', 'unit', 'name', 'valueReal', 'scaler', 'status', 'valTime', 'actTime', 'signature', 'unitName', + 'statRun', 'statFraudMagnet', 'statFraudCover', 'statEnergyTotal', 'statEnergyL1', 'statEnergyL2', 'statEnergyL3', + 'statRotaryField', 'statBackstop', 'statCalFault', 'statVoltageL1', 'statVoltageL2', 'statVoltageL3', 'obis' +] class Smartmeter(SmartPlugin, Conversion): @@ -151,19 +152,16 @@ def load_parameters(self): self._config['dlms'] = {} self._config['dlms']['device'] = self.get_parameter_value('device_address') self._config['dlms']['querycode'] = self.get_parameter_value('querycode') - self._config['dlms']['baudrate_fix'] = self.get_parameter_value('baudrate_fix') self._config['dlms']['baudrate_min'] = self.get_parameter_value('baudrate_min') self._config['dlms']['use_checksum'] = self.get_parameter_value('use_checksum') - self._config['dlms']['onlylisten'] = self.get_parameter_value('only_listen') - # self._config['dlms']['reset_baudrate'] = self.get_parameter_value('reset_baudrate') - # self._config['dlms']['no_waiting'] = self.get_parameter_value('no_waiting') + self._config['dlms']['only_listen'] = self.get_parameter_value('only_listen') # SML only # disabled parameters are for old frame parser self._config['sml'] = {} - # self._config['sml']['device'] = self.get_parameter_value('device_type') self._config['sml']['buffersize'] = self.get_parameter_value('buffersize') # 1024 - # self._config['sml']['date_offset'] = self.get_parameter_value('date_offset') # 0 + self._config['sml']['device'] = self.get_parameter_value('device_type') + self._config['sml']['date_offset'] = self.get_parameter_value('date_offset') # 0 # self._config['sml']['poly'] = self.get_parameter_value('poly') # 0x1021 # self._config['sml']['reflect_in'] = self.get_parameter_value('reflect_in') # True # self._config['sml']['xor_in'] = self.get_parameter_value('xor_in') # 0xffff @@ -255,7 +253,9 @@ def parse_item(self, item: Item) -> Union[Callable, None]: prop = 'value' index = self.get_iattr_value(item.conf, OBIS_INDEX, default=0) vtype = self.get_iattr_value(item.conf, OBIS_VTYPE, default='') - # TODO: crosscheck vtype and item type + if vtype in ('int', 'num', 'float', 'str'): + if vtype != item.type(): + self.logger.warning(f'item {item}: item type is {item.type()}, but obis_vtype is {vtype}, please fix item definition') self.add_item(item, {'property': prop, 'index': index, 'vtype': vtype}, obis) @@ -329,33 +329,30 @@ def _update_values(self, result: dict): if obis in self._items: entry = self._items[obis] for prop, items in entry.items(): - if prop not in PROPS: - self.logger.warning(f'invalid property {prop} requested for obis {obis}, ignoring') - continue for item in items: conf = self.get_item_config(item) index = conf.get('index', 0) - itemValue = vlist[index].get(prop, PROPS[prop]) - if prop == 'value': - try: - val = vlist[index][prop] - converter = conf['vtype'] - itemValue = self._convert_value(val, converter) - # self.logger.debug(f'conversion yielded {itemValue} from {val} for converter "{converter}"') - except IndexError: - self.logger.warning(f'value for index {index} not found in {vlist["value"]}, skipping...') - continue - except KeyError as e: - self.logger.warning(f'key error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') - except NameError as e: - self.logger.warning(f'name error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') - # TODO: add more props? -> sml! + # new default: if we don't find prop, we don't change the respective item + itemValue = vlist[index].get(prop) + try: + val = vlist[index][prop] + converter = conf['vtype'] + itemValue = self._convert_value(val, converter) + # self.logger.debug(f'conversion yielded {itemValue} from {val} for converter "{converter}"') + except IndexError: + self.logger.warning(f'value for index {index} not found in {vlist["value"]}, skipping...') + continue + except KeyError as e: + self.logger.warning(f'key error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') + except NameError as e: + self.logger.warning(f'name error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') + + # skip item assignment to save time and cpu cycles + if itemValue is not None: + item(itemValue, self.get_fullname()) + self.logger.debug(f'set item {item} for obis code {obis}:{prop} to value "{itemValue}"') else: - if itemValue is None: - itemValue = '' - - item(itemValue, self.get_fullname()) - self.logger.debug(f'set item {item} for obis code {obis}:{prop} to value "{itemValue}"') + self.logger.debug(f'for item {item} and obis code {obis}:{prop} no content was received') @property def item_list(self): diff --git a/smartmeter/dlms-sample.txt b/smartmeter/dlms-sample.txt deleted file mode 100644 index a44f99b23..000000000 --- a/smartmeter/dlms-sample.txt +++ /dev/null @@ -1,278 +0,0 @@ -1-1:F.F(00000000) -1-1:0.0.0(97734234) -1-1:0.0.1(97734234) -1-1:0.9.1(145051) -1-1:0.9.2(241129) -1-1:0.1.2(0000) -1-1:0.1.3(241101) -1-1:0.1.0(30) -1-1:1.2.1(0501.70*kW) -1-1:1.2.2(0501.70*kW) -1-1:2.2.1(0123.46*kW) -1-1:2.2.2(0123.46*kW) -1-1:1.6.1(07.98*kW)(2411061415) -1-1:1.6.1*30(07.60)(2410101115) -1-1:1.6.1*29(06.10)(2409160830) -1-1:1.6.1*28(05.35)(2408081545) -1-1:1.6.1*27(04.11)(2407181515) -1-1:1.6.1*26(05.26)(2406041400) -1-1:1.6.1*25(06.80)(2405311000) -1-1:1.6.1*24(04.50)(2404110945) -1-1:1.6.1*23(11.15)(2403051545) -1-1:1.6.1*22(09.15)(2402211445) -1-1:1.6.1*21(08.97)(2401191030) -1-1:1.6.1*20(24.08)(2312121045) -1-1:1.6.1*19(18.56)(2311060845) -1-1:1.6.1*18(23.05)(2310241530) -1-1:1.6.1*17(20.60)(2309111330) -1-1:1.6.1*16(21.48)(2308251330) -1-1:1.6.2(07.98*kW)(2411061415) -1-1:1.6.2*30(07.60)(2410101115) -1-1:1.6.2*29(06.10)(2409160830) -1-1:1.6.2*28(05.35)(2408081545) -1-1:1.6.2*27(04.11)(2407181515) -1-1:1.6.2*26(05.26)(2406041400) -1-1:1.6.2*25(06.80)(2405311000) -1-1:1.6.2*24(04.50)(2404110945) -1-1:1.6.2*23(11.15)(2403051545) -1-1:1.6.2*22(09.15)(2402211445) -1-1:1.6.2*21(08.97)(2401191030) -1-1:1.6.2*20(24.08)(2312121045) -1-1:1.6.2*19(18.56)(2311060845) -1-1:1.6.2*18(23.05)(2310241530) -1-1:1.6.2*17(20.60)(2309111330) -1-1:1.6.2*16(21.48)(2308251330) -1-1:2.6.1(01.84*kW)(2411021345) -1-1:2.6.1*30(03.32)(2410051445) -1-1:2.6.1*29(04.35)(2409011430) -1-1:2.6.1*28(05.62)(2408311415) -1-1:2.6.1*27(06.31)(2407141445) -1-1:2.6.1*26(06.43)(2406151330) -1-1:2.6.1*25(06.15)(2405251315) -1-1:2.6.1*24(05.84)(2404211345) -1-1:2.6.1*23(04.99)(2403251400) -1-1:2.6.1*22(02.58)(2402171330) -1-1:2.6.1*21(01.35)(2401271345) -1-1:2.6.1*20(00.54)(2312251200) -1-1:2.6.1*19(00.84)(2311121315) -1-1:2.6.1*18(03.24)(2310141415) -1-1:2.6.1*17(04.43)(2309031430) -1-1:2.6.1*16(05.76)(2308031445) -1-1:2.6.2(01.84*kW)(2411021345) -1-1:2.6.2*30(03.32)(2410051445) -1-1:2.6.2*29(04.35)(2409011430) -1-1:2.6.2*28(05.62)(2408311415) -1-1:2.6.2*27(06.31)(2407141445) -1-1:2.6.2*26(06.43)(2406151330) -1-1:2.6.2*25(06.15)(2405251315) -1-1:2.6.2*24(05.84)(2404211345) -1-1:2.6.2*23(04.99)(2403251400) -1-1:2.6.2*22(02.58)(2402171330) -1-1:2.6.2*21(01.35)(2401271345) -1-1:2.6.2*20(00.54)(2312251200) -1-1:2.6.2*19(00.84)(2311121315) -1-1:2.6.2*18(03.24)(2310141415) -1-1:2.6.2*17(04.43)(2309031430) -1-1:2.6.2*16(05.76)(2308031445) -1-1:1.8.0(00043802*kWh) -1-1:1.8.0*30(00042781) -1-1:1.8.0*29(00041912) -1-1:1.8.0*28(00041227) -1-1:1.8.0*27(00040639) -1-1:1.8.0*26(00040118) -1-1:1.8.0*25(00039674) -1-1:1.8.0*24(00039139) -1-1:1.8.0*23(00038600) -1-1:1.8.0*22(00037417) -1-1:1.8.0*21(00035961) -1-1:1.8.0*20(00034776) -1-1:1.8.0*19(00032557) -1-1:1.8.0*18(00030476) -1-1:1.8.0*17(00028420) -1-1:1.8.0*16(00026978) -1-1:2.8.0(00005983*kWh) -1-1:2.8.0*30(00005979) -1-1:2.8.0*29(00005931) -1-1:2.8.0*28(00005717) -1-1:2.8.0*27(00005284) -1-1:2.8.0*26(00004705) -1-1:2.8.0*25(00004135) -1-1:2.8.0*24(00003546) -1-1:2.8.0*23(00003198) -1-1:2.8.0*22(00003075) -1-1:2.8.0*21(00003063) -1-1:2.8.0*20(00003060) -1-1:2.8.0*19(00003059) -1-1:2.8.0*18(00003056) -1-1:2.8.0*17(00003027) -1-1:2.8.0*16(00002869) -1-1:3.8.0(00021203*kvarh) -1-1:3.8.0*30(00021129) -1-1:3.8.0*29(00021035) -1-1:3.8.0*28(00020920) -1-1:3.8.0*27(00020788) -1-1:3.8.0*26(00020697) -1-1:3.8.0*25(00020622) -1-1:3.8.0*24(00020429) -1-1:3.8.0*23(00020403) -1-1:3.8.0*22(00020116) -1-1:3.8.0*21(00019929) -1-1:3.8.0*20(00019739) -1-1:3.8.0*19(00018838) -1-1:3.8.0*18(00017921) -1-1:3.8.0*17(00016923) -1-1:3.8.0*16(00016094) -1-1:4.8.0(00006222*kvarh) -1-1:4.8.0*30(00005926) -1-1:4.8.0*29(00005638) -1-1:4.8.0*28(00005404) -1-1:4.8.0*27(00005179) -1-1:4.8.0*26(00004943) -1-1:4.8.0*25(00004722) -1-1:4.8.0*24(00004526) -1-1:4.8.0*23(00004306) -1-1:4.8.0*22(00004054) -1-1:4.8.0*21(00003799) -1-1:4.8.0*20(00003550) -1-1:4.8.0*19(00003344) -1-1:4.8.0*18(00003156) -1-1:4.8.0*17(00002957) -1-1:4.8.0*16(00002771) -1-1:1.8.1(00035256*kWh) -1-1:1.8.1*30(00034502) -1-1:1.8.1*29(00033921) -1-1:1.8.1*28(00033497) -1-1:1.8.1*27(00033174) -1-1:1.8.1*26(00032943) -1-1:1.8.1*25(00032746) -1-1:1.8.1*24(00032461) -1-1:1.8.1*23(00032177) -1-1:1.8.1*22(00031377) -1-1:1.8.1*21(00030337) -1-1:1.8.1*20(00029431) -1-1:1.8.1*19(00027499) -1-1:1.8.1*18(00025699) -1-1:1.8.1*17(00023923) -1-1:1.8.1*16(00022750) -1-1:1.8.2(00008545*kWh) -1-1:1.8.2*30(00008279) -1-1:1.8.2*29(00007990) -1-1:1.8.2*28(00007730) -1-1:1.8.2*27(00007465) -1-1:1.8.2*26(00007174) -1-1:1.8.2*25(00006927) -1-1:1.8.2*24(00006678) -1-1:1.8.2*23(00006422) -1-1:1.8.2*22(00006039) -1-1:1.8.2*21(00005623) -1-1:1.8.2*20(00005344) -1-1:1.8.2*19(00005057) -1-1:1.8.2*18(00004777) -1-1:1.8.2*17(00004496) -1-1:1.8.2*16(00004227) -1-1:2.8.1(00005983*kWh) -1-1:2.8.1*30(00005979) -1-1:2.8.1*29(00005931) -1-1:2.8.1*28(00005717) -1-1:2.8.1*27(00005284) -1-1:2.8.1*26(00004705) -1-1:2.8.1*25(00004135) -1-1:2.8.1*24(00003546) -1-1:2.8.1*23(00003198) -1-1:2.8.1*22(00003075) -1-1:2.8.1*21(00003063) -1-1:2.8.1*20(00003060) -1-1:2.8.1*19(00003059) -1-1:2.8.1*18(00003056) -1-1:2.8.1*17(00003027) -1-1:2.8.1*16(00002869) -1-1:2.8.2(00000000*kWh) -1-1:2.8.2*30(00000000) -1-1:2.8.2*29(00000000) -1-1:2.8.2*28(00000000) -1-1:2.8.2*27(00000000) -1-1:2.8.2*26(00000000) -1-1:2.8.2*25(00000000) -1-1:2.8.2*24(00000000) -1-1:2.8.2*23(00000000) -1-1:2.8.2*22(00000000) -1-1:2.8.2*21(00000000) -1-1:2.8.2*20(00000000) -1-1:2.8.2*19(00000000) -1-1:2.8.2*18(00000000) -1-1:2.8.2*17(00000000) -1-1:2.8.2*16(00000000) -1-1:3.8.1(00021081*kvarh) -1-1:3.8.1*30(00021007) -1-1:3.8.1*29(00020913) -1-1:3.8.1*28(00020800) -1-1:3.8.1*27(00020679) -1-1:3.8.1*26(00020597) -1-1:3.8.1*25(00020523) -1-1:3.8.1*24(00020330) -1-1:3.8.1*23(00020304) -1-1:3.8.1*22(00020023) -1-1:3.8.1*21(00019837) -1-1:3.8.1*20(00019647) -1-1:3.8.1*19(00018746) -1-1:3.8.1*18(00017829) -1-1:3.8.1*17(00016835) -1-1:3.8.1*16(00016012) -1-1:3.8.2(00000122*kvarh) -1-1:3.8.2*30(00000122) -1-1:3.8.2*29(00000122) -1-1:3.8.2*28(00000119) -1-1:3.8.2*27(00000109) -1-1:3.8.2*26(00000099) -1-1:3.8.2*25(00000099) -1-1:3.8.2*24(00000099) -1-1:3.8.2*23(00000099) -1-1:3.8.2*22(00000092) -1-1:3.8.2*21(00000092) -1-1:3.8.2*20(00000092) -1-1:3.8.2*19(00000092) -1-1:3.8.2*18(00000092) -1-1:3.8.2*17(00000088) -1-1:3.8.2*16(00000081) -1-1:4.8.1(00003666*kvarh) -1-1:4.8.1*30(00003482) -1-1:4.8.1*29(00003302) -1-1:4.8.1*28(00003159) -1-1:4.8.1*27(00003025) -1-1:4.8.1*26(00002882) -1-1:4.8.1*25(00002746) -1-1:4.8.1*24(00002628) -1-1:4.8.1*23(00002497) -1-1:4.8.1*22(00002342) -1-1:4.8.1*21(00002182) -1-1:4.8.1*20(00002019) -1-1:4.8.1*19(00001898) -1-1:4.8.1*18(00001790) -1-1:4.8.1*17(00001678) -1-1:4.8.1*16(00001572) -1-1:4.8.2(00002555*kvarh) -1-1:4.8.2*30(00002444) -1-1:4.8.2*29(00002335) -1-1:4.8.2*28(00002245) -1-1:4.8.2*27(00002153) -1-1:4.8.2*26(00002060) -1-1:4.8.2*25(00001975) -1-1:4.8.2*24(00001897) -1-1:4.8.2*23(00001809) -1-1:4.8.2*22(00001712) -1-1:4.8.2*21(00001616) -1-1:4.8.2*20(00001530) -1-1:4.8.2*19(00001446) -1-1:4.8.2*18(00001365) -1-1:4.8.2*17(00001279) -1-1:4.8.2*16(00001198) -1-1:C.3.1(___-----) -1-1:C.3.2(__------) -1-1:C.3.3(__------) -1-1:C.3.4(--__5_--) -1-1:C.4.0(60C00183) -1-1:C.5.0(0020E0F0) -1-1:C.7.0(00000041) -1-1:0.2.0(B31) -1-1:0.2.1(005) -! \ No newline at end of file diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py index 258b8671d..88c4d332b 100755 --- a/smartmeter/dlms.py +++ b/smartmeter/dlms.py @@ -34,11 +34,14 @@ __docformat__ = 'reStructuredText' import logging -import threading import time import serial +import socket # not needed, just for code portability from ruamel.yaml import YAML +from threading import Lock +from typing import Union + """ This module implements the query of a smartmeter using the DLMS protocol. @@ -58,13 +61,6 @@ """ -if __name__ == '__main__': - logger = logging.getLogger(__name__) - logger.debug(f"init standalone {__name__}") -else: - logger = logging.getLogger(__name__) - logger.debug(f"init plugin component {__name__}") - # # protocol constants # @@ -77,6 +73,29 @@ LF = 0x0A # linefeed BCC = 0x00 # Block check Character will contain the checksum immediately following the data packet +# serial config +S_BITS = serial.SEVENBITS +S_PARITY = serial.PARITY_EVEN +S_STOP = serial.STOPBITS_ONE + + +if __name__ == '__main__': + logger = logging.getLogger(__name__) + logger.debug(f"init standalone {__name__}") +else: + logger = logging.getLogger(__name__) + logger.debug(f"init plugin component {__name__}") + + +manufacturer_ids = {} +exportfile = 'manufacturer.yaml' +try: + with open(exportfile, 'r') as infile: + y = YAML(typ='safe') + manufacturer_ids = y.load(infile) +except Exception: + pass + # # internal testing @@ -93,14 +112,10 @@ else: RESULT = '' -manufacturer_ids = {} -exportfile = 'manufacturer.yaml' -try: - with open(exportfile, 'r') as infile: - y = YAML(typ='safe') - manufacturer_ids = y.load(infile) -except Exception: - pass + +# +# start module code +# def format_time(timedelta: float) -> str: @@ -132,16 +147,17 @@ def read_data_block_from_serial(the_serial: serial.Serial, end_byte: bytes = b'\ :param the_serial: interface to read from :param end_byte: the indicator for end of data, this will be included in response :param start_byte: the indicator for start of data, this will be included in response - :param max_read_time: + :param max_read_time: maximum time after which to stop reading even if data is still sent :returns the read data or None """ if TESTING: return RESULT.encode() - logger.debug("start to read data from serial device") + logger.debug(f"start to read data from serial device, start is {start_byte}, end is '{end_byte}, time is {max_read_time}") response = bytes() starttime = time.time() start_found = False + ch = bytes() try: while True: ch = the_serial.read() @@ -151,19 +167,23 @@ def read_data_block_from_serial(the_serial: serial.Serial, end_byte: bytes = b'\ break if start_byte != b'': if ch == start_byte: + logger.debug('start byte found') response = bytes() start_found = True response += ch if ch == end_byte: + logger.debug('end byte found') if start_byte is not None and not start_found: response = bytes() continue else: break if (response[-1] == end_byte): + logger.debug('end byte at end of response found') break if max_read_time is not None: - if runtime - starttime > max_read_time: + if runtime - starttime > max_read_time and max_read_time > 0: + logger.debug('max read time reached') break except Exception as e: logger.debug(f"error occurred while reading data block from serial: {e} ") @@ -206,7 +226,183 @@ def split_header(readout: str, break_at_eod: bool = True) -> list: return obis -def query(config) -> dict: +def get_sock(config) -> tuple[Union[serial.Serial, socket.socket, None], str]: + """ open serial or network socket """ + sock = None + serial_port = config.get('serial_port') + host = config.get('host') + port = config.get('port') + timeout = config.get('timeout', 2) + baudrate = config.get('DLMS', {'baudate_min': 300}).get('baudrate_min', 300) + + if TESTING: + return None, '(test input)' + + if serial_port: + # + # open the serial communication + # + try: # open serial + sock = serial.Serial( + serial_port, + baudrate, + S_BITS, + S_PARITY, + S_STOP, + timeout=timeout + ) + if not serial_port == sock.name: + logger.debug(f"Asked for {serial_port} as serial port, but really using now {sock.name}") + target = f'serial://{sock.name}' + + except FileNotFoundError: + logger.error(f"Serial port '{serial_port}' does not exist, please check your port") + return None, '' + except serial.SerialException: + if sock is None: + logger.error(f"Serial port '{serial_port}' could not be opened") + else: + logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") + return None, '' + except OSError: + logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") + return None, '' + except Exception as e: + logger.error(f"unforeseen error occurred: '{e}'") + return None, '' + + if sock is None: + # this should not happen... + logger.error("unforeseen error occurred, serial object was not initialized.") + return None, '' + + if not sock.is_open: + logger.error(f"serial port '{serial_port}' could not be opened with given parameters, maybe wrong baudrate?") + return None, '' + + elif host: + # + # open network connection + # + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + sock.connect((host, port)) + sock.setblocking(False) + target = f'tcp://{host}:{port}' + + else: + logger.error('neither serialport nor host/port was given, no action possible.') + return None, '' + + return sock, target + + +def check_protocol(data: bytes, only_listen=False, use_checksum=True) -> Union[str, None]: + """ check for proper protocol handling """ + acknowledge = b'' # preset empty answer + + if data.startswith(acknowledge): + if not only_listen: + logger.debug("acknowledge echoed from smartmeter") + data = data[len(acknowledge):] + + if use_checksum: + # data block in response may be capsuled within STX and ETX to provide error checking + # thus the response will contain a sequence of + # STX Datablock ! CR LF ETX BCC + # which means we need at least 6 characters in response where Datablock is empty + logger.debug("trying now to calculate a checksum") + + if data[0] == STX: + logger.debug("STX found") + else: + logger.warning(f"STX not found in response='{' '.join(hex(i) for i in data[:10])}...'") + + if data[-2] == ETX: + logger.debug("ETX found") + else: + logger.warning(f"ETX not found in response='...{' '.join(hex(i) for i in data[-11:])}'") + + if (len(data) > 5) and (data[0] == STX) and (data[-2] == ETX): + # perform checks (start with char after STX, end with ETX including, checksum matches last byte (BCC)) + BCC = data[-1] + logger.debug(f"block check character BCC is {BCC}") + checksum = 0 + for i in data[1:-1]: + checksum ^= i + if checksum != BCC: + logger.warning(f"checksum/protocol error: response={' '.join(hex(i) for i in data[1:-1])}, checksum={checksum}") + return + else: + logger.debug("checksum over data response was ok, data is valid") + else: + logger.warning("STX - ETX not found") + else: + logger.debug("checksum calculation skipped") + + if not only_listen: + if len(data) > 5: + res = str(data[1:-4], 'ascii') + else: + logger.debug("response did not contain enough data for OBIS decode") + return + else: + res = str(data, 'ascii') + + return res + + +def parse(data: str) -> dict: + """ parse data returned from device read """ + + result = {} + obis = split_header(data) + + try: + for line in obis: + # Now check if we can split between values and OBIS code + arguments = line.split('(') + if len(arguments) == 1: + # no values found at all; that seems to be a wrong OBIS code line then + arguments = arguments[0] + values = "" + logger.warning(f"OBIS code line without data item: {line}") + else: + # ok, found some values to the right, lets isolate them + values = arguments[1:] + obis_code = arguments[0] + + temp_values = values + values = [] + for s in temp_values: + s = s.replace(')', '') + if len(s) > 0: + # we now should have a list with values that may contain a number + # separated from a unit by a '*' or a date + # so see, if there is an '*' within + vu = s.split('*') + if len(vu) > 2: + logger.error(f"too many '*' found in '{s}' of '{line}'") + elif len(vu) == 2: + # just a value and a unit + v = vu[0] + u = vu[1] + values.append({'value': v, 'unit': u}) + else: + # just a value, no unit + v = vu[0] + values.append({'value': v}) + # uncomment the following line to check the generation of the values dictionary + logger.debug(f"{line:40} ---> {values}") + result[obis_code] = values + logger.debug("finished processing lines") + except Exception as e: + logger.debug(f"error while extracting data: '{e}'") + + return result + + +def query(config) -> Union[dict, None]: """ This function will 1. open a serial communication line to the smartmeter @@ -220,7 +416,7 @@ def query(config) -> dict: config contains a dict with entries for 'serial_port', 'device' and a sub-dict 'dlms' with entries for - 'querycode', 'baudrate', 'baudrate_fix', 'timeout', 'onlylisten', 'use_checksum' + 'querycode', 'baudrate', 'baudrate_fix', 'timeout', 'only_listen', 'use_checksum' return: a dict with the response data formatted as follows: { @@ -233,8 +429,6 @@ def query(config) -> dict: The obis lines contain at least one value (index 0), possibly with a unit, and possibly more values in analogous format """ - # TODO: modularize; find components to reuse with SML? - # # initialize module # @@ -242,118 +436,85 @@ def query(config) -> dict: # for the performance of the serial read we need to save the current time starttime = time.time() runtime = starttime - result = None - lock = threading.Lock() + lock = Lock() + sock = None - try: - serial_port = config['serial_port'] - timeout = config['timeout'] + if not ('serial_port' in config or ('host' in config and 'port' in config)): + logger.warning(f'configuration {config} is missing source config (serialport or host and port)') + return + try: device = config['dlms']['device'] initial_baudrate = config['dlms']['baudrate_min'] - # baudrate_fix = config['dlms']['baudrate_fix'] query_code = config['dlms']['querycode'] use_checksum = config['dlms']['use_checksum'] - only_listen = config['dlms'].get('onlylisten', False) # just for the case that smartmeter transmits data without a query first + only_listen = config['dlms'].get('only_listen', False) # just for the case that smartmeter transmits data without a query first except (KeyError, AttributeError) as e: logger.warning(f'configuration {config} is missing elements: {e}') - return {} + return logger.debug(f"config='{config}'") - start_char = b'/' - - request_message = b"/" + query_code.encode('ascii') + device.encode('ascii') + b"!\r\n" # # open the serial communication # - # about timeout: time tr between sending a request and an answer needs to be - # 200ms < tr < 1500ms for protocol mode A or B - # inter character time must be smaller than 1500 ms - # The time between the reception of a message and the transmission of an answer is: - # (20 ms) 200 ms = tr = 1 500 ms (see item 12) of 6.3.14). - # If a response has not been received, the waiting time of the transmitting equipment after - # transmission of the identification message, before it continues with the transmission, is: - # 1 500 ms < tt = 2 200 ms - # The time between two characters in a character sequence is: - # ta < 1 500 ms - wait_before_acknowledge = 0.4 # wait for 400 ms before sending the request to change baudrate - wait_after_acknowledge = 0.4 # wait for 400 ms after sending acknowledge - dlms_serial = None - locked = lock.acquire(blocking=False) if not locked: logger.error('could not get lock for serial access. Is another scheduled/manual action still active?') - return {} + return try: # lock release - if not TESTING: - try: # open serial - dlms_serial = serial.Serial(serial_port, - initial_baudrate, - bytesize=serial.SEVENBITS, - parity=serial.PARITY_EVEN, - stopbits=serial.STOPBITS_ONE, - timeout=timeout) - if not serial_port == dlms_serial.name: - logger.debug(f"Asked for {serial_port} as serial port, but really using now {dlms_serial.name}") - - except FileNotFoundError: - logger.error(f"Serial port '{serial_port}' does not exist, please check your port") - return {} - except serial.SerialException: - if dlms_serial is None: - logger.error(f"Serial port '{serial_port}' could not be opened") - else: - logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") - return {} - except OSError: - logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") - return {} - except Exception as e: - logger.error(f"unforeseen error occurred: '{e}'") - return {} - if dlms_serial is None: - # this should not happen... - logger.error("unforeseen error occurred, serial object was not initialized.") - return {} + sock, target = get_sock(config) + if not sock: + # error already logged, just go + return - if not dlms_serial.is_open: - logger.error(f"serial port '{serial_port}' could not be opened with given parameters, maybe wrong baudrate?") - return {} + if isinstance(sock, socket.socket): + logger.error(f'network reading not yet implemented for DLMS at {target}') + return - logger.debug(f"time to open serial port {serial_port}: {format_time(time.time() - runtime)}") runtime = time.time() - - acknowledge = b'' # preset empty answer + logger.debug(f"time to open {target}: {format_time(time.time() - runtime)}") + + # + # read data from device + # + + # about timeout: time tr between sending a request and an answer needs to be + # 200ms < tr < 1500ms for protocol mode A or B + # inter character time must be smaller than 1500 ms + # The time between the reception of a message and the transmission of an answer is: + # (20 ms) 200 ms = tr = 1 500 ms (see item 12) of 6.3.14). + # If a response has not been received, the waiting time of the transmitting equipment after + # transmission of the identification message, before it continues with the transmission, is: + # 1 500 ms < tt = 2 200 ms + # The time between two characters in a character sequence is: + # ta < 1 500 ms + wait_before_acknowledge = 0.4 # wait for 400 ms before sending the request to change baudrate + wait_after_acknowledge = 0.4 # wait for 400 ms after sending acknowledge + start_char = b'/' + request_message = b"/" + query_code.encode('ascii') + device.encode('ascii') + b"!\r\n" if not only_listen: - # TODO: check/implement later response = b'' # start a dialog with smartmeter try: - # TODO: is this needed? when? - # logger.debug(f"Reset input buffer from serial port '{serial_port}'") - # dlms_serial.reset_input_buffer() # replaced dlms_serial.flushInput() - logger.debug(f"writing request message {request_message} to serial port '{serial_port}'") - dlms_serial.write(request_message) - # TODO: same as above - # logger.debug(f"Flushing buffer from serial port '{serial_port}'") - # dlms_serial.flush() # replaced dlms_serial.drainOutput() + logger.debug(f"writing request message {request_message} to serial port '{target}'") + sock.write(request_message) except Exception as e: logger.warning(f"error on serial write: {e}") - return {} + return logger.debug(f"time to send first request to smartmeter: {format_time(time.time() - runtime)}") # now get first response - response = read_data_block_from_serial(dlms_serial) + response = read_data_block_from_serial(sock) if not response: logger.debug("no response received upon first request") - return {} + return logger.debug(f"time to receive an answer: {format_time(time.time() - runtime)}") runtime = time.time() @@ -364,7 +525,7 @@ def query(config) -> dict: logger.debug("request message was echoed, need to read the identification message") # now read the capabilities and type/brand line from Smartmeter # e.g. b'/LGZ5\\2ZMD3104407.B32\r\n' - response = read_data_block_from_serial(dlms_serial) + response = read_data_block_from_serial(sock) else: logger.debug("request message was not equal to response, treating as identification message") @@ -381,11 +542,11 @@ def query(config) -> dict: # 2 bytes CR LF if len(identification_message) < 7: logger.warning(f"malformed identification message: '{identification_message}', abort query") - return {} + return if (identification_message[0] != start_char): logger.warning(f"identification message '{identification_message}' does not start with '/', abort query") - return {} + return manid = str(identification_message[1:4], 'utf-8') manname = manufacturer_ids.get(manid, 'unknown') @@ -446,16 +607,16 @@ def query(config) -> dict: time.sleep(wait_before_acknowledge) logger.debug(f"using protocol mode C, send acknowledge {acknowledge} and tell smartmeter to switch to {new_baudrate} baud") try: - dlms_serial.write(acknowledge) + sock.write(acknowledge) except Exception as e: logger.warning(f"error on sending baudrate change: {e}") - return {} + return time.sleep(wait_after_acknowledge) # dlms_serial.flush() # dlms_serial.reset_input_buffer() if (new_baudrate != initial_baudrate): # change request to set higher baudrate - dlms_serial.baudrate = new_baudrate + sock.baudrate = new_baudrate elif protocol_mode == 'B': # the speed change in communication is initiated from the smartmeter device @@ -466,17 +627,17 @@ def query(config) -> dict: # dlms_serial.reset_input_buffer() if (new_baudrate != initial_baudrate): # change request to set higher baudrate - dlms_serial.baudrate = new_baudrate + sock.baudrate = new_baudrate else: logger.debug(f"no change of readout baudrate, smartmeter and reader will stay at {new_baudrate} baud") # now read the huge data block with all the OBIS codes logger.debug("Reading OBIS data from smartmeter") - response = read_data_block_from_serial(dlms_serial, b'') + response = read_data_block_from_serial(sock, b'') else: # only listen mode, starts with / and last char is ! # data will be in between those two - response = read_data_block_from_serial(dlms_serial, b'!', b'/') + response = read_data_block_from_serial(sock, b'!', b'/') identification_message = str(response, 'utf-8').splitlines()[0] @@ -485,7 +646,7 @@ def query(config) -> dict: logger.debug(f"manufacturer for {manid} is {manname} (out of {len(manufacturer_ids)} given manufacturers)") try: - dlms_serial.close() + sock.close() except Exception: pass except Exception: @@ -500,108 +661,16 @@ def query(config) -> dict: # Display performance of the serial communication logger.debug(f"whole communication with smartmeter took {format_time(time.time() - starttime)}") - if response.startswith(acknowledge): - if not only_listen: - logger.debug("acknowledge echoed from smartmeter") - response = response[len(acknowledge):] - - if use_checksum: - # data block in response may be capsuled within STX and ETX to provide error checking - # thus the response will contain a sequence of - # STX Datablock ! CR LF ETX BCC - # which means we need at least 6 characters in response where Datablock is empty - logger.debug("trying now to calculate a checksum") - - if response[0] == STX: - logger.debug("STX found") - else: - logger.warning(f"STX not found in response='{' '.join(hex(i) for i in response[:10])}...'") - - if response[-2] == ETX: - logger.debug("ETX found") - else: - logger.warning(f"ETX not found in response='...{' '.join(hex(i) for i in response[-11:])}'") - - if (len(response) > 5) and (response[0] == STX) and (response[-2] == ETX): - # perform checks (start with char after STX, end with ETX including, checksum matches last byte (BCC)) - BCC = response[-1] - logger.debug(f"block check character BCC is {BCC}") - checksum = 0 - for i in response[1:-1]: - checksum ^= i - if checksum != BCC: - logger.warning(f"checksum/protocol error: response={' '.join(hex(i) for i in response[1:-1])} " - "checksum={checksum}") - return - else: - logger.debug("checksum over data response was ok, data is valid") - else: - logger.warning("STX - ETX not found") - else: - logger.debug("checksum calculation skipped") - - if not only_listen: - if len(response) > 5: - result = str(response[1:-4], 'ascii') - logger.debug(f"parsing OBIS codes took {format_time(time.time() - runtime)}") - else: - logger.debug("response did not contain enough data for OBIS decode") - else: - result = str(response, 'ascii') + response = check_protocol(response, only_listen, use_checksum) + if not response: + return + logger.debug(f"parsing OBIS codes took {format_time(time.time() - runtime)}") suggested_cycle = (time.time() - starttime) + 10.0 config['suggested_cycle'] = suggested_cycle logger.debug(f"the whole query took {format_time(time.time() - starttime)}, suggested cycle thus is at least {format_time(suggested_cycle)}") - if not result: - return {} - - rdict = {} # {'readout': result} - - obis = split_header(result) - - try: - for line in obis: - # Now check if we can split between values and OBIS code - arguments = line.split('(') - if len(arguments) == 1: - # no values found at all; that seems to be a wrong OBIS code line then - arguments = arguments[0] - values = "" - logger.warning(f"OBIS code line without data item: {line}") - else: - # ok, found some values to the right, lets isolate them - values = arguments[1:] - obis_code = arguments[0] - - temp_values = values - values = [] - for s in temp_values: - s = s.replace(')', '') - if len(s) > 0: - # we now should have a list with values that may contain a number - # separated from a unit by a '*' or a date - # so see, if there is an '*' within - vu = s.split('*') - if len(vu) > 2: - logger.error(f"too many '*' found in '{s}' of '{line}'") - elif len(vu) == 2: - # just a value and a unit - v = vu[0] - u = vu[1] - values.append({'value': v, 'unit': u}) - else: - # just a value, no unit - v = vu[0] - values.append({'value': v}) - # uncomment the following line to check the generation of the values dictionary - logger.debug(f"{line:40} ---> {values}") - rdict[obis_code] = values - logger.debug("finished processing lines") - except Exception as e: - logger.debug(f"error while extracting data: '{e}'") - - return rdict + return parse(response) def discover(config: dict) -> bool: @@ -612,14 +681,17 @@ def discover(config: dict) -> bool: # the user, or preset by the plugin.yaml defaults. # If really necessary, the query could be called multiple times with # reduced baud rates or changed parameters, but there would need to be - # the need for this. + # the need for this. # For now, let's see how well this works... result = query(config) # result should have one key 'readout' with the full answer and a separate # key for every read OBIS code. If no OBIS codes are read/converted, we can # not be sure this is really DLMS, so we check for at least one OBIS code. - return len(result) > 1 + if result: + return len(result) > 1 + else: + return False if __name__ == '__main__': @@ -640,15 +712,32 @@ def discover(config: dict) -> bool: args = parser.parse_args() - config = {} + # complete default dict + config = { + 'serial_port': '', + 'host': '', + 'port': 0, + 'connection': '', + 'timeout': 2, + 'baudrate': 9600, + 'dlms': { + 'device': '', + 'querycode': '?', + 'baudrate_min': 300, + 'use_checksum': True, + 'onlylisten': False + }, + 'sml': { + 'buffersize': 1024 + } + } config['serial_port'] = args.port config['timeout'] = args.timeout - config['dlms'] = {} config['dlms']['querycode'] = args.querycode config['dlms']['baudrate_min'] = args.baudrate config['dlms']['baudrate_fix'] = args.baudrate_fix - config['dlms']['onlylisten'] = args.onlylisten + config['dlms']['only_listen'] = args.onlylisten config['dlms']['use_checksum'] = args.nochecksum config['dlms']['device'] = args.device @@ -663,9 +752,9 @@ def discover(config: dict) -> bool: # add the handlers to the logger logging.getLogger().addHandler(ch) else: - logging.getLogger().setLevel(logging.DEBUG) + logging.getLogger().setLevel(logging.INFO) ch = logging.StreamHandler() - ch.setLevel(logging.DEBUG) + ch.setLevel(logging.INFO) # just like print formatter = logging.Formatter('%(message)s') ch.setFormatter(formatter) @@ -685,7 +774,13 @@ def discover(config: dict) -> bool: del result['readout'] except KeyError: pass - logger.info(result) + try: + import pprint + except ImportError: + txt = str(result) + else: + txt = pprint.pformat(result, indent=4) + logger.info(txt) elif len(result) == 1: logger.info("The results of the query could not be processed; raw result is:") logger.info(result) diff --git a/smartmeter/dlms_test.py b/smartmeter/dlms_test.py old mode 100644 new mode 100755 diff --git a/smartmeter/plugin.yaml b/smartmeter/plugin.yaml index 69fee8ca2..b0e03a6bd 100644 --- a/smartmeter/plugin.yaml +++ b/smartmeter/plugin.yaml @@ -35,6 +35,16 @@ parameters: description: de: 'Serieller Port, an dem das Smartmeter angeschlossen ist' en: 'serial port at which the smartmeter is attached' + host: + type: str + description: + de: 'Host der eine IP Schnittstelle bereitstellt (nur SML)' + en: 'Host that provides an IP interface (SML only)' + port: + type: int + description: + de: 'Port für die Kommunikation (nur SML)' + en: 'Port for communication (SML only)' timeout: type: int default: 2 @@ -69,12 +79,6 @@ parameters: description: de: 'Baudrate, bei der die Kommunikation zuerst erfolgen soll (nur DLMS)' en: 'Baudrate at which the communication should be initiated (DLMS only)' - baudrate_fix: - type: bool - default: false - description: - de: 'Baudrate beibehalten trotz Änderungsanforderung (nur DLMS)' - en: 'Keep up baudrate in communication despite of change request' device_address: type: str default: '' @@ -95,7 +99,7 @@ parameters: en: 'if true then a checksum will be calculated of the readout result' only_listen: type: bool - default: False + default: false description: de: 'Manche Smartmeter können nicht abgefragt werden sondern senden von sich aus Informationen. Für diese Smartmeter auf True setzen und die Baudrate anpassen (nur DLMS)' en: 'Some smartmeter can not be queried, they send information without request. For those devices set to True and adjust baudrate' @@ -107,29 +111,19 @@ parameters: description: de: 'Größe des Lesepuffers. Mindestens doppelte Größe der maximalen Nachrichtenlänge in Bytes (nur SML)' en: 'Size of read buffer. At least twice the size of maximum message length (SML only)' - host: + device_type: type: str + default: 'raw' description: - de: 'Host der eine IP Schnittstelle bereitstellt (nur SML)' - en: 'Host that provides an IP interface (SML only)' - port: + de: 'Name des Gerätes (nur SML)' + en: 'Name of Smartmeter (SML only)' + date_offset: type: int + default: 0 description: - de: 'Port für die Kommunikation (nur SML)' - en: 'Port for communication (SML only)' + de: 'Unix timestamp der Smartmeter Inbetriebnahme (nur SML)' + en: 'Unix timestamp of Smartmeter start-up after installation (SML only)' # the following parameters are for the old frame parser - # device_type: - # type: str - # default: 'raw' - # description: - # de: 'Name des Gerätes (nur SML)' - # en: 'Name of Smartmeter (SML only)' - # date_offset: - # type: int - # default: 0 - # description: - # de: 'Unix timestamp der Smartmeter Inbetriebnahme (nur SML)' - # en: 'Unix timestamp of Smartmeter start-up after installation (SML only)' # poly: # type: int # default: 0x1021 diff --git a/smartmeter/sml.py b/smartmeter/sml.py old mode 100644 new mode 100755 index df62e4135..5854dd309 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -45,9 +45,6 @@ from threading import Lock from typing import Union -# needed for old frame parser -# from .crc import Crc - """ This module implements the query of a smartmeter using the SML protocol. @@ -75,6 +72,11 @@ '0100605a0201': 'Prüfsumme', } +# serial config +S_BITS = serial.EIGHTBITS +S_PARITY = serial.PARITY_NONE +S_STOP = serial.STOPBITS_ONE + if __name__ == '__main__': logger = logging.getLogger(__name__) @@ -97,7 +99,12 @@ from .sml_test import RESULT logger.error('SML testing mode enabled, no serial communication, no real results!') else: - RESULT = '' + RESULT = b'' + + +# +# start module code +# def to_hex(data: Union[int, str, bytes, bytearray], space: bool = True) -> str: @@ -107,6 +114,12 @@ def to_hex(data: Union[int, str, bytes, bytearray], space: bool = True) -> str: if isinstance(data, int): return hex(data) + if isinstance(data, str): + if space: + return " ".join([data[i:i + 2] for i in range(0, len(data), 2)]) + else: + return data + templ = "%02x" if space: templ += " " @@ -179,21 +192,186 @@ def read(sock: Union[serial.Serial, socket.socket], length: int = 0) -> bytes: logger.debug(f"finished reading data from serial/network {len(response)} bytes") return response -# -# needed for old parser -# -# def swap16(x: int) -> int: -# return (((x << 8) & 0xFF00) | ((x >> 8) & 0x00FF)) -# -# def swap32(x): -# return (((x << 24) & 0xFF000000) | ((x << 8) & 0x00FF0000) | ((x >> 8) & 0x0000FF00) | ((x >> 24) & 0x000000FF)) -# -# def prepareHex(data: bytes) -> bytes: -# data2 = data.decode("iso-8859-1").lower() -# data2 = re.sub("[^a-f0-9]", " ", data2) -# data2 = re.sub("( +[a-f0-9]|[a-f0-9] +)", "", data2) -# data2 = data2.encode() -# return bytes(''.join(chr(int(data[i:i + 2], 16)) for i in range(0, len(data), 2)), "iso8859-1") + +def get_sock(config: dict) -> tuple[Union[serial.Serial, socket.socket, None], str]: + """ open serial or network socket """ + sock = None + serial_port = config.get('serial_port') + host = config.get('host') + port = config.get('port') + timeout = config.get('timeout', 2) + baudrate = config.get('baudrate', 9600) + + if TESTING: + return None, '(test input)' + + if serial_port: + # + # open the serial communication + # + try: # open serial + sock = serial.Serial( + serial_port, + baudrate, + S_BITS, + S_PARITY, + S_STOP, + timeout=timeout + ) + if not serial_port == sock.name: + logger.debug(f"Asked for {serial_port} as serial port, but really using now {sock.name}") + target = f'serial://{sock.name}' + + except FileNotFoundError: + logger.error(f"Serial port '{serial_port}' does not exist, please check your port") + return None, '' + except serial.SerialException: + if sock is None: + logger.error(f"Serial port '{serial_port}' could not be opened") + else: + logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") + return None, '' + except OSError: + logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") + return None, '' + except Exception as e: + logger.error(f"unforeseen error occurred: '{e}'") + return None, '' + + if sock is None: + # this should not happen... + logger.error("unforeseen error occurred, serial object was not initialized.") + return None, '' + + if not sock.is_open: + logger.error(f"serial port '{serial_port}' could not be opened with given parameters, maybe wrong baudrate?") + return None, '' + + elif host: + # + # open network connection + # + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + sock.connect((host, port)) + sock.setblocking(False) + target = f'tcp://{host}:{port}' + + else: + logger.error('neither serialport nor host/port was given, no action possible.') + return None, '' + + return sock, target + + +def parse(data: bytes, config: dict) -> dict: + """ parse data returned from device read """ + result = {} + stream = SmlStreamReader() + stream.add(data) + + while True: + try: + frame = stream.get_frame() + if frame is None: + break + + obis_values = frame.get_obis() + for entry in obis_values: + code = entry.obis.obis_code + if code not in result: + result[code] = [] + content = { + 'value': entry.get_value(), + 'name': OBIS_NAMES.get(entry.obis), + 'valueReal': entry.get_value() + } + if entry.scaler: + content['scaler'] = entry.scaler + content['valueReal'] = round(content['value'] * 10 ** content['scaler'], 1) + if entry.status: + content['status'] = entry.status + if entry.val_time: + content['valTime'] = entry.val_time + content['actTime'] = time.ctime(config.get('date_offset', 0) + entry.val_time) + if entry.value_signature: + content['signature'] = entry.value_signature + if entry.unit: + content['unit'] = entry.unit + content['unitName'] = smlConst.UNITS.get(content['unit']) + + # Decoding status information if present + if 'status' in content: + # for bitwise operation, true-ish result means bit is set + content['statRun'] = bool((content['status'] >> 8) & 1) # True: meter is counting, False: standstill + content['statFraudMagnet'] = bool((content['status'] >> 8) & 2) # True: magnetic manipulation detected, False: ok + content['statFraudCover'] = bool((content['status'] >> 8) & 4) # True: cover manipulation detected, False: ok + content['statEnergyTotal'] = bool((content['status'] >> 8) & 8) # Current flow total. True: -A, False: +A + content['statEnergyL1'] = bool((content['status'] >> 8) & 16) # Current flow L1. True: -A, False: +A + content['statEnergyL2'] = bool((content['status'] >> 8) & 32) # Current flow L2. True: -A, False: +A + content['statEnergyL3'] = bool((content['status'] >> 8) & 64) # Current flow L3. True: -A, False: +A + content['statRotaryField'] = bool((content['status'] >> 8) & 128) # True: rotary field not L1->L2->L3, False: ok + content['statBackstop'] = bool((content['status'] >> 8) & 256) # True: backstop active, False: backstop not active + content['statCalFault'] = bool((content['status'] >> 8) & 512) # True: calibration relevant fatal fault, False: ok + content['statVoltageL1'] = bool((content['status'] >> 8) & 1024) # True: Voltage L1 present, False: not present + content['statVoltageL2'] = bool((content['status'] >> 8) & 2048) # True: Voltage L2 present, False: not present + content['statVoltageL3'] = bool((content['status'] >> 8) & 4096) # True: Voltage L3 present, False: not present + + # TODO: for backward compatibility - check if really needed + content['obis'] = code + # Convert some special OBIS values into nicer format + # EMH ED300L: add additional OBIS codes + if content['obis'] == '1-0:0.2.0*0': + content['valueReal'] = content['value'].decode() # Firmware as UTF-8 string + if content['obis'] == '1-0:96.50.1*1' or content['obis'] == '129-129:199.130.3*255': + content['valueReal'] = content['value'].decode() # Manufacturer code as UTF-8 string + if content['obis'] == '1-0:96.1.0*255' or content['obis'] == '1-0:0.0.9*255': + content['valueReal'] = to_hex(content['value']) + if content['obis'] == '1-0:96.5.0*255': + content['valueReal'] = bin(content['value'] >> 8) # Status as binary string, so not decoded into status bits as above + # end TODO + + result[code].append(content) + logger.debug(f"found {code} with {content}") + except Exception as e: + detail = traceback.format_exc() + logger.warning(f'parsing data failed with error: {e}; details are {detail}') + # at least return what was decoded up to now + return result + + return result + + # old frame parser, possibly remove later (needs add'l helper and not-presend "parse" routine) + # if START_SEQUENCE in data: + # prev, _, data = data.partition(START_SEQUENCE) + # logger.debug(f'start sequence marker {to_hex(START_SEQUENCE)} found') + # if END_SEQUENCE in data: + # data, _, remainder = data.partition(END_SEQUENCE) + # logger.debug(f'end sequence marker {to_hex(END_SEQUENCE)} found') + # logger.debug(f'packet size is {len(data)}') + # if len(remainder) > 3: + # filler = remainder[0] + # logger.debug(f'{filler} fill byte(s) ') + # checksum = int.from_bytes(remainder[1:3], byteorder='little') + # logger.debug(f'Checksum is {to_hex(checksum)}') + # buffer = bytearray() + # buffer += START_SEQUENCE + data + END_SEQUENCE + remainder[0:1] + # logger.debug(f'Buffer length is {len(buffer)}') + # logger.debug(f'buffer is: {to_hex(buffer)}') + # crc16 = Crc(width=16, poly=poly, reflect_in=reflect_in, xor_in=xor_in, reflect_out=reflect_out, xor_out=xor_out) + # crc_calculated = crc16.table_driven(buffer) + # if swap_crc_bytes: + # logger.debug(f'calculated swapped checksum is {to_hex(swap16(crc_calculated))}, given CRC is {to_hex(checksum)}') + # crc_calculated = swap16(crc_calculated) + # else: + # logger.debug(f'calculated checksum is {to_hex(crc_calculated)}, given CRC is {to_hex(checksum)}') + # data_is_valid = crc_calculated == checksum + # else: + # logger.debug('not enough bytes read at end to satisfy checksum calculation') + # else: + # logger.debug('no End sequence marker found in data') + # else: + # logger.debug('no Start sequence marker found in data') def query(config) -> dict: @@ -218,8 +396,6 @@ def query(config) -> dict: } """ - # TODO: modularize; find components to reuse with DLMS? - # # initialize module # @@ -229,102 +405,46 @@ def query(config) -> dict: runtime = starttime result = {} lock = Lock() + sock = None - try: - serial_port = config.get('serial_port') - host = config.get('host') - port = config.get('port') - timeout = config['timeout'] - - buffersize = config['sml']['buffersize'] - - # needed for old parser - # device = config['sml']['device'] - # date_offset = config['sml']['date_offset'] - # - # poly = config['sml']['poly'] - # reflect_in = config['sml']['reflect_in'] - # xor_in = config['sml']['xor_in'] - # reflect_out = config['sml']['reflect_out'] - # xor_out = config['sml']['xor_out'] - # swap_crc_bytes = config['sml']['swap_crc_bytes'] - - except (KeyError, AttributeError) as e: - logger.warning(f'configuration {config} is missing elements: {e}') + if not ('serial_port' in config or ('host' in config and 'port' in config)): + logger.warning(f'configuration {config} is missing source config (serialport or host and port)') return {} + buffersize = config.get('sml', {'buffersize': 1024}).get('buffersize', 1024) + logger.debug(f"config='{config}'") + # + # open the serial communication + # + locked = lock.acquire(blocking=False) if not locked: logger.error('could not get lock for serial/network access. Is another scheduled/manual action still active?') - return {} + return result - locked = lock.acquire(blocking=False) try: # lock release - sock = None - if serial_port and not TESTING: - # - # open the serial communication - # - try: # open serial - sock = serial.Serial(serial_port, 9600, serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE, timeout=timeout) - if not serial_port == sock.name: - logger.debug(f"Asked for {serial_port} as serial port, but really using now {sock.name}") - target = f'serial://{sock.name}' - - except FileNotFoundError: - logger.error(f"Serial port '{serial_port}' does not exist, please check your port") - return {} - except serial.SerialException: - if sock is None: - logger.error(f"Serial port '{serial_port}' could not be opened") - else: - logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") - return {} - except OSError: - logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") - return {} - except Exception as e: - logger.error(f"unforeseen error occurred: '{e}'") - return {} - if sock is None: - # this should not happen... - logger.error("unforeseen error occurred, serial object was not initialized.") - return {} - - if not sock.is_open: - logger.error(f"serial port '{serial_port}' could not be opened with given parameters, maybe wrong baudrate?") - return {} - - logger.debug(f"time to open serial port {serial_port}: {format_time(time.time() - runtime)}") - runtime = time.time() - elif host and not TESTING: - # - # open network connection - # - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - sock.connect((host, port)) - sock.setblocking(False) - target = f'tcp://{host}:{port}' - - elif not TESTING: - logger.error('neither serialport nor host/port was given, no action possible.') - return {} + sock, target = get_sock(config) + if not sock: + # error already logged, just go + return result + runtime = time.time() + logger.debug(f"time to open {target}: {format_time(time.time() - runtime)}") # # read data from device # - data = bytes() + + response = bytes() try: - data = read(sock, buffersize) - if len(data) == 0: + response = read(sock, buffersize) + if len(response) == 0: logger.error('reading data from device returned 0 bytes!') - return {} + return result else: - logger.debug(f'read {len(data)} bytes') + logger.debug(f'read {len(response)} bytes') except Exception as e: logger.error(f'reading data from {target} failed with error: {e}') @@ -353,71 +473,7 @@ def query(config) -> dict: # parse data # - stream = SmlStreamReader() - stream.add(data) - - while True: - try: - frame = stream.get_frame() - if frame is None: - break - - obis_values = frame.get_obis() - for sml_entry in obis_values: - code = sml_entry.obis.obis_code - if code not in result: - result[code] = [] - content = { - 'value': sml_entry.get_value(), - 'name': OBIS_NAMES.get(sml_entry.obis), - 'scaler': sml_entry.scaler, - 'status': sml_entry.status, - 'valTime': sml_entry.val_time, - 'signature': sml_entry.value_signature - } - if sml_entry.unit: - content['unit'] = smlConst.UNITS.get(sml_entry.unit) - result[code].append(content) - - except Exception as e: - detail = traceback.format_exc() - logger.warning(f'parsing data failed with error: {e}; details are {detail}') - # at least return what was decoded up to now - return result - - # old frame parser, possibly remove later (needs add'l helper and not-presend "parse" routine) - # if START_SEQUENCE in data: - # prev, _, data = data.partition(START_SEQUENCE) - # logger.debug(f'start sequence marker {to_hex(START_SEQUENCE)} found') - # if END_SEQUENCE in data: - # data, _, remainder = data.partition(END_SEQUENCE) - # logger.debug(f'end sequence marker {to_hex(END_SEQUENCE)} found') - # logger.debug(f'packet size is {len(data)}') - # if len(remainder) > 3: - # filler = remainder[0] - # logger.debug(f'{filler} fill byte(s) ') - # checksum = int.from_bytes(remainder[1:3], byteorder='little') - # logger.debug(f'Checksum is {to_hex(checksum)}') - # buffer = bytearray() - # buffer += START_SEQUENCE + data + END_SEQUENCE + remainder[0:1] - # logger.debug(f'Buffer length is {len(buffer)}') - # logger.debug(f'buffer is: {to_hex(buffer)}') - # crc16 = Crc(width=16, poly=poly, reflect_in=reflect_in, xor_in=xor_in, reflect_out=reflect_out, xor_out=xor_out) - # crc_calculated = crc16.table_driven(buffer) - # if swap_crc_bytes: - # logger.debug(f'calculated swapped checksum is {to_hex(swap16(crc_calculated))}, given CRC is {to_hex(checksum)}') - # crc_calculated = swap16(crc_calculated) - # else: - # logger.debug(f'calculated checksum is {to_hex(crc_calculated)}, given CRC is {to_hex(checksum)}') - # data_is_valid = crc_calculated == checksum - # else: - # logger.debug('not enough bytes read at end to satisfy checksum calculation') - # else: - # logger.debug('no End sequence marker found in data') - # else: - # logger.debug('no Start sequence marker found in data') - - return result + return parse(response, config) def discover(config: dict) -> bool: @@ -432,7 +488,7 @@ def discover(config: dict) -> bool: # For now, let's see how well this works... result = query(config) - return result != {} + return bool(result) if __name__ == '__main__': @@ -448,11 +504,29 @@ def discover(config: dict) -> bool: args = parser.parse_args() - config = {} + # complete default dict + config = { + 'serial_port': '', + 'host': '', + 'port': 0, + 'connection': '', + 'timeout': 2, + 'baudrate': 9600, + 'dlms': { + 'device': '', + 'querycode': '?', + 'baudrate_min': 300, + 'use_checksum': True, + 'onlylisten': False + }, + 'sml': { + 'buffersize': 1024 + } + } config['serial_port'] = args.port config['timeout'] = args.timeout - config['sml'] = {'buffersize': args.buffersize} + config['sml']['buffersize'] = args.buffersize if args.verbose: logging.getLogger().setLevel(logging.DEBUG) @@ -465,9 +539,9 @@ def discover(config: dict) -> bool: # add the handlers to the logger logging.getLogger().addHandler(ch) else: - logging.getLogger().setLevel(logging.DEBUG) + logging.getLogger().setLevel(logging.INFO) ch = logging.StreamHandler() - ch.setLevel(logging.DEBUG) + ch.setLevel(logging.INFO) # just like print formatter = logging.Formatter('%(message)s') ch.setFormatter(formatter) @@ -487,7 +561,13 @@ def discover(config: dict) -> bool: del result['readout'] except KeyError: pass - logger.info(result) + try: + import pprint + except ImportError: + txt = str(result) + else: + txt = pprint.pformat(result, indent=4) + logger.info(txt) elif len(result) == 1: logger.info("The results of the query could not be processed; raw result is:") logger.info(result) diff --git a/smartmeter/sml_test.py b/smartmeter/sml_test.py old mode 100644 new mode 100755 From 92d1358968e7e4b4c966a5216d1c7e89f17c73f0 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Fri, 6 Dec 2024 17:48:49 +0100 Subject: [PATCH 10/34] smartmeter: further aligned dlms and sml value/property return --- smartmeter/__init__.py | 29 +++++++------- smartmeter/conversion.py | 12 ++++-- smartmeter/dlms.py | 83 +++++++++++++++++++++++++++++++++++++--- smartmeter/plugin.yaml | 58 +++++++++++++++++++++------- smartmeter/sml.py | 16 ++++---- 5 files changed, 153 insertions(+), 45 deletions(-) diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index a1ff3f5fb..7d4e2e554 100755 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -67,7 +67,7 @@ # obis properties PROPS = [ - 'value', 'unit', 'name', 'valueReal', 'scaler', 'status', 'valTime', 'actTime', 'signature', 'unitName', + 'value', 'unit', 'name', 'valueRaw', 'scaler', 'status', 'valTime', 'actTime', 'signature', 'unitCode', 'statRun', 'statFraudMagnet', 'statFraudCover', 'statEnergyTotal', 'statEnergyL1', 'statEnergyL2', 'statEnergyL3', 'statRotaryField', 'statBackstop', 'statCalFault', 'statVoltageL1', 'statVoltageL2', 'statVoltageL3', 'obis' ] @@ -155,6 +155,7 @@ def load_parameters(self): self._config['dlms']['baudrate_min'] = self.get_parameter_value('baudrate_min') self._config['dlms']['use_checksum'] = self.get_parameter_value('use_checksum') self._config['dlms']['only_listen'] = self.get_parameter_value('only_listen') + self._config['dlms']['normalize'] = self.get_parameter_value('normalize') # SML only # disabled parameters are for old frame parser @@ -334,21 +335,21 @@ def _update_values(self, result: dict): index = conf.get('index', 0) # new default: if we don't find prop, we don't change the respective item itemValue = vlist[index].get(prop) - try: - val = vlist[index][prop] - converter = conf['vtype'] - itemValue = self._convert_value(val, converter) - # self.logger.debug(f'conversion yielded {itemValue} from {val} for converter "{converter}"') - except IndexError: - self.logger.warning(f'value for index {index} not found in {vlist["value"]}, skipping...') - continue - except KeyError as e: - self.logger.warning(f'key error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') - except NameError as e: - self.logger.warning(f'name error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') - # skip item assignment to save time and cpu cycles if itemValue is not None: + try: + val = vlist[index][prop] + converter = conf['vtype'] + itemValue = self._convert_value(val, converter) + # self.logger.debug(f'conversion yielded {itemValue} from {val} for converter "{converter}"') + except IndexError: + self.logger.warning(f'value for index {index} not found in {vlist["value"]}, skipping...') + continue + except KeyError as e: + self.logger.warning(f'key error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') + except NameError as e: + self.logger.warning(f'name error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') + item(itemValue, self.get_fullname()) self.logger.debug(f'set item {item} for obis code {obis}:{prop} to value "{itemValue}"') else: diff --git a/smartmeter/conversion.py b/smartmeter/conversion.py index 7a35c5a9d..76d276349 100755 --- a/smartmeter/conversion.py +++ b/smartmeter/conversion.py @@ -128,10 +128,14 @@ def _convert_value(self, val, converter: str = ''): return val try: - if converter in ('num', 'float'): - - if converter == 'num' and val.isdigit(): - return int(val) + if converter in ('num', 'float', 'int'): + + if converter in ('num', 'int'): + try: + return int(val) + except (ValueError, AttributeError): + if converter == 'int': + raise ValueError # try/except to catch floats like '1.0' and '1,0' try: diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py index 88c4d332b..98cc0dd77 100755 --- a/smartmeter/dlms.py +++ b/smartmeter/dlms.py @@ -35,12 +35,14 @@ import logging import time +from jinja2.ext import ExprStmtExtension import serial import socket # not needed, just for code portability from ruamel.yaml import YAML +from smllib import const as smlConst from threading import Lock -from typing import Union +from typing import (Union, Tuple, Any) """ @@ -118,6 +120,66 @@ # +def normalize_unit(value: Union[int, float], unit: str) -> Tuple[Union[int, float], str]: + """ normalize units, i.e. remove prefixes and recalculate value """ + # in this environment, smaller or larger prefixes don't seem sensible... + _prefix = { + 'u': 1e-6, # micro + 'm': 1e-3, # mili + 'c': 1e-2, # centi + 'd': 1e-1, # deci + 'k': 1e3, # kilo + 'M': 1e6, # mega + 'G': 1e9, # giga + } + + nval = value + nunit = unit + + for p in _prefix: + if unit.startswith(p): + nunit = unit[1:] + nval = nval * _prefix[p] + break + + # check if we made a float without necessity... + if unit != nunit and type(value) is int and type(nval) is float and _prefix[unit[0]] > 1: + nval = int(nval) + + return nval, nunit + + +def get_unit_code(value: Any, unit: str, normalize: bool = True) -> Tuple[Any, str, Union[int, None]]: + """ + try to get unit code for u from sml Units. If normalize is set, first try to + normalize value/unit. + As SML only lists base units, prefixes units like kW or MWh don't match + the value/unit pair for prefixed units and we don't return unit codes that + need normalizing. + """ + unit_code = None + + # check if value is numeric + x = None + try: + x = float(value) + except Exception: + pass + try: + x = int(value) + except Exception: + pass + + # only check for numeric values... + if type(x) in (int, float) and unit: + if normalize: + value, unit = normalize_unit(x, unit) + if unit in smlConst.UNITS.values(): + unit_code = list(smlConst.UNITS.keys())[list(smlConst.UNITS.values()).index(unit)] + + return value, unit, unit_code + + def format_time(timedelta: float) -> str: """ returns a pretty formatted string according to the size of the timedelta @@ -352,7 +414,7 @@ def check_protocol(data: bytes, only_listen=False, use_checksum=True) -> Union[s return res -def parse(data: str) -> dict: +def parse(data: str, normalize: bool = True) -> dict: """ parse data returned from device read """ result = {} @@ -387,7 +449,14 @@ def parse(data: str) -> dict: # just a value and a unit v = vu[0] u = vu[1] - values.append({'value': v, 'unit': u}) + + # normalize SI units if possible to return values analogue to SML (e.g. Wh instead of kWh) + v, u, uc = get_unit_code(v, u, normalize) + + if uc: + values.append({'value': v, 'unit': u, 'unitCode': uc}) + else: + values.append({'value': v, 'unit': u}) else: # just a value, no unit v = vu[0] @@ -449,6 +518,7 @@ def query(config) -> Union[dict, None]: query_code = config['dlms']['querycode'] use_checksum = config['dlms']['use_checksum'] only_listen = config['dlms'].get('only_listen', False) # just for the case that smartmeter transmits data without a query first + normalize = config['dlms'].get('normalize', True) except (KeyError, AttributeError) as e: logger.warning(f'configuration {config} is missing elements: {e}') return @@ -670,7 +740,7 @@ def query(config) -> Union[dict, None]: config['suggested_cycle'] = suggested_cycle logger.debug(f"the whole query took {format_time(time.time() - starttime)}, suggested cycle thus is at least {format_time(suggested_cycle)}") - return parse(response) + return parse(response, normalize) def discover(config: dict) -> bool: @@ -709,6 +779,7 @@ def discover(config: dict) -> bool: parser.add_argument('-l', '--onlylisten', help='only listen to serial, no active query', action='store_true') parser.add_argument('-f', '--baudrate_fix', help='keep baudrate speed fixed', action='store_false') parser.add_argument('-c', '--nochecksum', help='don\'t use a checksum', action='store_false') + parser.add_argument('-n', '--normalize', help='convert units to base units and recalculate value', action='store_true') args = parser.parse_args() @@ -725,7 +796,8 @@ def discover(config: dict) -> bool: 'querycode': '?', 'baudrate_min': 300, 'use_checksum': True, - 'onlylisten': False + 'onlylisten': False, + 'normalize': True }, 'sml': { 'buffersize': 1024 @@ -740,6 +812,7 @@ def discover(config: dict) -> bool: config['dlms']['only_listen'] = args.onlylisten config['dlms']['use_checksum'] = args.nochecksum config['dlms']['device'] = args.device + config['dlms']['normalize'] = args.normalize if args.verbose: logging.getLogger().setLevel(logging.DEBUG) diff --git a/smartmeter/plugin.yaml b/smartmeter/plugin.yaml index b0e03a6bd..73deec5a3 100644 --- a/smartmeter/plugin.yaml +++ b/smartmeter/plugin.yaml @@ -103,6 +103,12 @@ parameters: description: de: 'Manche Smartmeter können nicht abgefragt werden sondern senden von sich aus Informationen. Für diese Smartmeter auf True setzen und die Baudrate anpassen (nur DLMS)' en: 'Some smartmeter can not be queried, they send information without request. For those devices set to True and adjust baudrate' + normalize: + type: bool + default: true + description: + de: 'Wenn Einheiten mit Zehnerpräfix geliefert werden, werden Einheit und Wert in die entsprechende Basiseinheit konvertiert (z.B. 12.7 kWh -> 12700 Wh)' + en: 'if units are read with power of ten prefix, unit and value will be converted to the corresponding base unit (e.g. 12.7 kWh -> 12700 Wh)' # SML parameters buffersize: @@ -180,29 +186,53 @@ item_attributes: type: str default: value valid_list: - - value - - unit - - scaler - - status - - valTime - - signature + - value # value, possibly corrected (scaler, normalized unit) + - valueRaw # value as sent from meter + - unit # unit as str + - unitCode # unit code from smllib.const.UNITS + - name # + - obis # obis code + - valTime # time as sent from meter + - actTime # time corrected for meter time base + - scaler # 10**x scaler for valueRaw + - signature # meter signature + - status # status bit field + - statRun # meter is running + - statFraudMagnet # fraud magnet activated + - statFraudCover # fraud cover triggered + - statEnergyTotal # + - statEnergyL1 # + - statEnergyL2 # + - statEnergyL3 # + - statRotaryField # + - statBackstop # + - statCalFault # + - statVoltageL1 # + - statVoltageL2 # + - statVoltageL3 # description: de: > Eigenschaft des gelesenen Wertes: - * value: gelesener Wert (siehe obis_index) + * value: korrigierter Wert (Skalierungsfaktor, SI-Basiseinheit) + * valueRaw: vom Gerät gelieferter (Roh-)Wert * unit: zurückgegebene Einheit des Wertes - * scaler: Multiplikationsfaktor - * status: tbd + * unitCode: Code der Einheit gem. smllib.const.UNITS + * scaler: Multiplikationsfaktor für valueRaw + * status: Status-Bitfeld * valTime: Zeitstempel des Wertes - * signature: tbc + * actTime: korrigierter Zeitstempel des Wertes + * signature: tbd Nicht alle Eigenschaften sind in jedem Datenpunkt vorhanden. en: > property of the read data: - * value: read obis value (see obis_index) + * value: corrected value (scaler, SI base units) + * valueRaw: value as read from meter * unit: read unit of value - * scaler: multiplicative factor - * status: tbd - * valTime: timestamp of the value + * unitCode: code for unit as given in smllib.const.UNITS + * scaler: multiplicative factor for valueRaw + * status: meter status bitfield + * valTime: timestamp for value + * actTime: corrected timestamp for value * signature: tbd Not all properties are present for all data points. obis_vtype: diff --git a/smartmeter/sml.py b/smartmeter/sml.py index 5854dd309..b2240bdb9 100755 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -284,11 +284,11 @@ def parse(data: bytes, config: dict) -> dict: content = { 'value': entry.get_value(), 'name': OBIS_NAMES.get(entry.obis), - 'valueReal': entry.get_value() + 'valueRaw': entry.get_value() } if entry.scaler: content['scaler'] = entry.scaler - content['valueReal'] = round(content['value'] * 10 ** content['scaler'], 1) + content['value'] = round(content['value'] * 10 ** content['scaler'], 1) if entry.status: content['status'] = entry.status if entry.val_time: @@ -297,8 +297,8 @@ def parse(data: bytes, config: dict) -> dict: if entry.value_signature: content['signature'] = entry.value_signature if entry.unit: - content['unit'] = entry.unit - content['unitName'] = smlConst.UNITS.get(content['unit']) + content['unit'] = smlConst.UNITS.get(entry.unit) + content['unitCode'] = entry.unit # Decoding status information if present if 'status' in content: @@ -322,13 +322,13 @@ def parse(data: bytes, config: dict) -> dict: # Convert some special OBIS values into nicer format # EMH ED300L: add additional OBIS codes if content['obis'] == '1-0:0.2.0*0': - content['valueReal'] = content['value'].decode() # Firmware as UTF-8 string + content['value'] = content['valueRaw'].decode() # Firmware as UTF-8 string if content['obis'] == '1-0:96.50.1*1' or content['obis'] == '129-129:199.130.3*255': - content['valueReal'] = content['value'].decode() # Manufacturer code as UTF-8 string + content['value'] = content['valueRaw'].decode() # Manufacturer code as UTF-8 string if content['obis'] == '1-0:96.1.0*255' or content['obis'] == '1-0:0.0.9*255': - content['valueReal'] = to_hex(content['value']) + content['value'] = to_hex(content['valueRaw']) if content['obis'] == '1-0:96.5.0*255': - content['valueReal'] = bin(content['value'] >> 8) # Status as binary string, so not decoded into status bits as above + content['value'] = bin(content['valueRaw'] >> 8) # Status as binary string, so not decoded into status bits as above # end TODO result[code].append(content) From 873c6090d5bc82f1544a018ba8d5ec433bcf4839 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Sat, 7 Dec 2024 08:25:18 +0100 Subject: [PATCH 11/34] smartmeter: refactor code for asyncio implementation --- smartmeter/sml.py | 530 +++++++++++++++++++++++++--------------------- 1 file changed, 291 insertions(+), 239 deletions(-) diff --git a/smartmeter/sml.py b/smartmeter/sml.py index b2240bdb9..23e0c681a 100755 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -144,202 +144,306 @@ def format_time(timedelta): return f"{timedelta * 1000000000.0:.2f} ns" -def _read(sock, length: int) -> bytes: - """ isolate the read method from the connection object """ - if isinstance(sock, serial.Serial): - return sock.read() - elif isinstance(sock, socket.socket): - return sock.recv(length) - else: - return b'' +# +# single-shot reader +# -def read(sock: Union[serial.Serial, socket.socket], length: int = 0) -> bytes: - """ - This function reads some bytes from serial or network interface - it returns an array of bytes if a timeout occurs or a given end byte is encountered - and otherwise b'' if an error occurred - :returns the read data - """ - if TESTING: - return RESULT - - logger.debug("start to read data from serial/network device") - response = bytes() - while True: - try: - # on serial, length is ignored - data = _read(sock, length) - if data: - response += data - if len(response) >= length: - logger.debug('read end, length reached') - break - else: - if isinstance(sock, serial.Serial): - logger.debug('read end, end of data reached') - break - except socket.error as e: - if e.args[0] == errno.EAGAIN or e.args[0] == errno.EWOULDBLOCK: - logger.debug(f'read end, error: {e}') - break - else: - raise - except Exception as e: - logger.debug(f"error while reading from serial/network: {e}") - return b'' +class SmlReader(): + def __init__(self, logger, config: dict): + self.config = config + self.sock = None + self.logger = logger - logger.debug(f"finished reading data from serial/network {len(response)} bytes") - return response + if not ('serial_port' in config or ('host' in config and 'port' in config)): + raise ValueError(f'configuration {config} is missing source config (serialport or host and port)') + self.serial_port = config.get('serial_port') + self.host = config.get('host') + self.port = config.get('port') + self.timeout = config.get('timeout', 2) + self.baudrate = config.get('baudrate', 9600) + self.target = '(not set)' + self.buffersize = config.get('sml', {'buffersize': 1024}).get('buffersize', 1024) + self.lock = Lock() -def get_sock(config: dict) -> tuple[Union[serial.Serial, socket.socket, None], str]: - """ open serial or network socket """ - sock = None - serial_port = config.get('serial_port') - host = config.get('host') - port = config.get('port') - timeout = config.get('timeout', 2) - baudrate = config.get('baudrate', 9600) + logger.debug(f"config='{config}'") - if TESTING: - return None, '(test input)' + def __call__(self) -> bytes: - if serial_port: # # open the serial communication # - try: # open serial - sock = serial.Serial( - serial_port, - baudrate, - S_BITS, - S_PARITY, - S_STOP, - timeout=timeout - ) - if not serial_port == sock.name: - logger.debug(f"Asked for {serial_port} as serial port, but really using now {sock.name}") - target = f'serial://{sock.name}' - - except FileNotFoundError: - logger.error(f"Serial port '{serial_port}' does not exist, please check your port") - return None, '' - except serial.SerialException: - if sock is None: - logger.error(f"Serial port '{serial_port}' could not be opened") - else: - logger.error(f"Serial port '{serial_port}' could be opened but somehow not accessed") - return None, '' - except OSError: - logger.error(f"Serial port '{serial_port}' does not exist, please check the spelling") - return None, '' - except Exception as e: - logger.error(f"unforeseen error occurred: '{e}'") - return None, '' - if sock is None: - # this should not happen... - logger.error("unforeseen error occurred, serial object was not initialized.") - return None, '' + locked = self.lock.acquire(blocking=False) + if not locked: + logger.error('could not get lock for serial/network access. Is another scheduled/manual action still active?') + return b'' + + try: # lock release + + runtime = time.time() + self.get_sock() + if not self.sock: + # error already logged, just go + return b'' + logger.debug(f"time to open {self.target}: {format_time(time.time() - runtime)}") - if not sock.is_open: - logger.error(f"serial port '{serial_port}' could not be opened with given parameters, maybe wrong baudrate?") + # + # read data from device + # + + response = bytes() + try: + response = self.read() + if len(response) == 0: + logger.error('reading data from device returned 0 bytes!') + return b'' + else: + logger.debug(f'read {len(response)} bytes') + + except Exception as e: + logger.error(f'reading data from {self.target} failed with error: {e}') + + except Exception: + # passthrough, this is only for releasing the lock + raise + finally: + # + # clean up connection + # + try: + self.sock.close() + except Exception: + pass + self.sock = None + self.lock.release() + + return response + + def _read(self) -> bytes: + """ isolate the read method from the connection object """ + if isinstance(self.sock, serial.Serial): + return self.sock.read() + elif isinstance(self.sock, socket.socket): + return self.sock.recv(self.buffersize) + else: + return b'' + + def read(self) -> bytes: + """ + This function reads some bytes from serial or network interface + it returns an array of bytes if a timeout occurs or a given end byte is encountered + and otherwise b'' if an error occurred + :returns the read data + """ + if TESTING: + return RESULT + + self.logger.debug("start to read data from serial/network device") + response = bytes() + while True: + try: + # on serial, length is ignored + data = self._read() + if data: + response += data + if len(response) >= self.buffersize: + self.logger.debug('read end, length reached') + break + else: + if isinstance(self.sock, serial.Serial): + self.logger.debug('read end, end of data reached') + break + except socket.error as e: + if e.args[0] == errno.EAGAIN or e.args[0] == errno.EWOULDBLOCK: + self.logger.debug(f'read end, error: {e}') + break + else: + raise + except Exception as e: + self.logger.debug(f"error while reading from serial/network: {e}") + return b'' + + self.logger.debug(f"finished reading data from serial/network {len(response)} bytes") + return response + + def get_sock(self): + """ open serial or network socket """ + + if TESTING: + self.sock = None + self.target = '(test input)' + return + + if self.serial_port: + # + # open the serial communication + # + try: # open serial + self.sock = serial.Serial( + self.serial_port, + self.baudrate, + S_BITS, + S_PARITY, + S_STOP, + timeout=self.timeout + ) + if not self.serial_port == self.sock.name: + logger.debug(f"Asked for {self.serial_port} as serial port, but really using now {self.sock.name}") + self.target = f'serial://{self.sock.name}' + + except FileNotFoundError: + logger.error(f"Serial port '{self.serial_port}' does not exist, please check your port") + return None, '' + except serial.SerialException: + if self.sock is None: + logger.error(f"Serial port '{self.serial_port}' could not be opened") + else: + logger.error(f"Serial port '{self.serial_port}' could be opened but somehow not accessed") + return None, '' + except OSError: + logger.error(f"Serial port '{self.serial_port}' does not exist, please check the spelling") + return None, '' + except Exception as e: + logger.error(f"unforeseen error occurred: '{e}'") + return None, '' + + if self.sock is None: + # this should not happen... + logger.error("unforeseen error occurred, serial object was not initialized.") + return None, '' + + if not self.sock.is_open: + logger.error(f"serial port '{self.serial_port}' could not be opened with given parameters, maybe wrong baudrate?") + return None, '' + + elif self.host: + # + # open network connection + # + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + sock.connect((self.host, self.port)) + sock.setblocking(False) + self.target = f'tcp://{self.host}:{self.port}' + + else: + logger.error('neither serialport nor host/port was given, no action possible.') return None, '' - elif host: - # - # open network connection - # - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - sock.connect((host, port)) - sock.setblocking(False) - target = f'tcp://{host}:{port}' - else: - logger.error('neither serialport nor host/port was given, no action possible.') - return None, '' +# +# frame parser +# - return sock, target +class SmlFrameParser(): + def __init__(self): + self.result = {} -def parse(data: bytes, config: dict) -> dict: - """ parse data returned from device read """ - result = {} - stream = SmlStreamReader() - stream.add(data) + def __call__(self, frame=None): + if frame is None: + res = self.result + self.result = {} + return res + else: + self.parse_frame(frame) + return self.result + + def parse_frame(self, frame): + """ parse single SML frame and add data to result dict """ + obis_values = frame.get_obis() + for entry in obis_values: + code = entry.obis.obis_code + if code not in self.result: + self.result[code] = [] + content = { + 'value': entry.get_value(), + 'name': OBIS_NAMES.get(entry.obis), + 'valueRaw': entry.get_value() + } + if entry.scaler: + content['scaler'] = entry.scaler + content['value'] = round(content['value'] * 10 ** content['scaler'], 1) + if entry.status: + content['status'] = entry.status + if entry.val_time: + content['valTime'] = entry.val_time + content['actTime'] = time.ctime(config.get('date_offset', 0) + entry.val_time) + if entry.value_signature: + content['signature'] = entry.value_signature + if entry.unit: + content['unit'] = smlConst.UNITS.get(entry.unit) + content['unitCode'] = entry.unit + + # Decoding status information if present + if 'status' in content: + # for bitwise operation, true-ish result means bit is set + content['statRun'] = bool((content['status'] >> 8) & 1) # True: meter is counting, False: standstill + content['statFraudMagnet'] = bool((content['status'] >> 8) & 2) # True: magnetic manipulation detected, False: ok + content['statFraudCover'] = bool((content['status'] >> 8) & 4) # True: cover manipulation detected, False: ok + content['statEnergyTotal'] = bool((content['status'] >> 8) & 8) # Current flow total. True: -A, False: +A + content['statEnergyL1'] = bool((content['status'] >> 8) & 16) # Current flow L1. True: -A, False: +A + content['statEnergyL2'] = bool((content['status'] >> 8) & 32) # Current flow L2. True: -A, False: +A + content['statEnergyL3'] = bool((content['status'] >> 8) & 64) # Current flow L3. True: -A, False: +A + content['statRotaryField'] = bool((content['status'] >> 8) & 128) # True: rotary field not L1->L2->L3, False: ok + content['statBackstop'] = bool((content['status'] >> 8) & 256) # True: backstop active, False: backstop not active + content['statCalFault'] = bool((content['status'] >> 8) & 512) # True: calibration relevant fatal fault, False: ok + content['statVoltageL1'] = bool((content['status'] >> 8) & 1024) # True: Voltage L1 present, False: not present + content['statVoltageL2'] = bool((content['status'] >> 8) & 2048) # True: Voltage L2 present, False: not present + content['statVoltageL3'] = bool((content['status'] >> 8) & 4096) # True: Voltage L3 present, False: not present + + # TODO: for backward compatibility - check if really needed + content['obis'] = code + # Convert some special OBIS values into nicer format + # EMH ED300L: add additional OBIS codes + if content['obis'] == '1-0:0.2.0*0': + content['value'] = content['valueRaw'].decode() # Firmware as UTF-8 string + if content['obis'] == '1-0:96.50.1*1' or content['obis'] == '129-129:199.130.3*255': + content['value'] = content['valueRaw'].decode() # Manufacturer code as UTF-8 string + if content['obis'] == '1-0:96.1.0*255' or content['obis'] == '1-0:0.0.9*255': + content['value'] = to_hex(content['valueRaw']) + if content['obis'] == '1-0:96.5.0*255': + content['value'] = bin(content['valueRaw'] >> 8) # Status as binary string, so not decoded into status bits as above + # end TODO + + self.result[code].append(content) + logger.debug(f"found {code} with {content}") + + +# +# cyclic parser +# - while True: - try: - frame = stream.get_frame() - if frame is None: - break - - obis_values = frame.get_obis() - for entry in obis_values: - code = entry.obis.obis_code - if code not in result: - result[code] = [] - content = { - 'value': entry.get_value(), - 'name': OBIS_NAMES.get(entry.obis), - 'valueRaw': entry.get_value() - } - if entry.scaler: - content['scaler'] = entry.scaler - content['value'] = round(content['value'] * 10 ** content['scaler'], 1) - if entry.status: - content['status'] = entry.status - if entry.val_time: - content['valTime'] = entry.val_time - content['actTime'] = time.ctime(config.get('date_offset', 0) + entry.val_time) - if entry.value_signature: - content['signature'] = entry.value_signature - if entry.unit: - content['unit'] = smlConst.UNITS.get(entry.unit) - content['unitCode'] = entry.unit - - # Decoding status information if present - if 'status' in content: - # for bitwise operation, true-ish result means bit is set - content['statRun'] = bool((content['status'] >> 8) & 1) # True: meter is counting, False: standstill - content['statFraudMagnet'] = bool((content['status'] >> 8) & 2) # True: magnetic manipulation detected, False: ok - content['statFraudCover'] = bool((content['status'] >> 8) & 4) # True: cover manipulation detected, False: ok - content['statEnergyTotal'] = bool((content['status'] >> 8) & 8) # Current flow total. True: -A, False: +A - content['statEnergyL1'] = bool((content['status'] >> 8) & 16) # Current flow L1. True: -A, False: +A - content['statEnergyL2'] = bool((content['status'] >> 8) & 32) # Current flow L2. True: -A, False: +A - content['statEnergyL3'] = bool((content['status'] >> 8) & 64) # Current flow L3. True: -A, False: +A - content['statRotaryField'] = bool((content['status'] >> 8) & 128) # True: rotary field not L1->L2->L3, False: ok - content['statBackstop'] = bool((content['status'] >> 8) & 256) # True: backstop active, False: backstop not active - content['statCalFault'] = bool((content['status'] >> 8) & 512) # True: calibration relevant fatal fault, False: ok - content['statVoltageL1'] = bool((content['status'] >> 8) & 1024) # True: Voltage L1 present, False: not present - content['statVoltageL2'] = bool((content['status'] >> 8) & 2048) # True: Voltage L2 present, False: not present - content['statVoltageL3'] = bool((content['status'] >> 8) & 4096) # True: Voltage L3 present, False: not present - - # TODO: for backward compatibility - check if really needed - content['obis'] = code - # Convert some special OBIS values into nicer format - # EMH ED300L: add additional OBIS codes - if content['obis'] == '1-0:0.2.0*0': - content['value'] = content['valueRaw'].decode() # Firmware as UTF-8 string - if content['obis'] == '1-0:96.50.1*1' or content['obis'] == '129-129:199.130.3*255': - content['value'] = content['valueRaw'].decode() # Manufacturer code as UTF-8 string - if content['obis'] == '1-0:96.1.0*255' or content['obis'] == '1-0:0.0.9*255': - content['value'] = to_hex(content['valueRaw']) - if content['obis'] == '1-0:96.5.0*255': - content['value'] = bin(content['valueRaw'] >> 8) # Status as binary string, so not decoded into status bits as above - # end TODO - - result[code].append(content) - logger.debug(f"found {code} with {content}") - except Exception as e: - detail = traceback.format_exc() - logger.warning(f'parsing data failed with error: {e}; details are {detail}') - # at least return what was decoded up to now - return result - - return result + +class SmlParser(): + def __init__(self): + self.fp = SmlFrameParser() + + def __call__(self, data: bytes) -> dict: + return self.parse(data) + + def parse(self, data: bytes) -> dict: + """ parse data returned from device read """ + stream = SmlStreamReader() + stream.add(data) + + while True: + try: + frame = stream.get_frame() + if frame is None: + break + + self.fp(frame) + + except Exception as e: + detail = traceback.format_exc() + logger.warning(f'parsing data failed with error: {e}; details are {detail}') + # at least return what was decoded up to now + return self.fp() + + return self.fp() # old frame parser, possibly remove later (needs add'l helper and not-presend "parse" routine) # if START_SEQUENCE in data: @@ -403,65 +507,13 @@ def query(config) -> dict: # for the performance of the serial read we need to save the current time starttime = time.time() runtime = starttime - result = {} - lock = Lock() - sock = None - if not ('serial_port' in config or ('host' in config and 'port' in config)): - logger.warning(f'configuration {config} is missing source config (serialport or host and port)') + try: + reader = SmlReader(logger, config) + except ValueError as e: + logger.error(f'error on opening connection: {e}') return {} - - buffersize = config.get('sml', {'buffersize': 1024}).get('buffersize', 1024) - - logger.debug(f"config='{config}'") - - # - # open the serial communication - # - - locked = lock.acquire(blocking=False) - if not locked: - logger.error('could not get lock for serial/network access. Is another scheduled/manual action still active?') - return result - - try: # lock release - - sock, target = get_sock(config) - if not sock: - # error already logged, just go - return result - runtime = time.time() - logger.debug(f"time to open {target}: {format_time(time.time() - runtime)}") - - # - # read data from device - # - - response = bytes() - try: - response = read(sock, buffersize) - if len(response) == 0: - logger.error('reading data from device returned 0 bytes!') - return result - else: - logger.debug(f'read {len(response)} bytes') - - except Exception as e: - logger.error(f'reading data from {target} failed with error: {e}') - - except Exception: - # passthrough, this is only for releasing the lock - raise - finally: - # - # clean up connection - # - try: - sock.close() - except Exception: - pass - sock = None - lock.release() + result = reader() logger.debug(f"time for reading OBIS data: {format_time(time.time() - runtime)}") runtime = time.time() @@ -473,7 +525,8 @@ def query(config) -> dict: # parse data # - return parse(response, config) + parser = SmlParser() + return parser(result) def discover(config: dict) -> bool: @@ -486,9 +539,7 @@ def discover(config: dict) -> bool: # reduced baud rates or changed parameters, but there would need to be # the need for this. # For now, let's see how well this works... - result = query(config) - - return bool(result) + return bool(query(config)) if __name__ == '__main__': @@ -517,7 +568,8 @@ def discover(config: dict) -> bool: 'querycode': '?', 'baudrate_min': 300, 'use_checksum': True, - 'onlylisten': False + 'onlylisten': False, + 'normalize': True }, 'sml': { 'buffersize': 1024 From 70b67b9a4a6b0fd1e56d45ab0ac44e3e761f98e2 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Sat, 7 Dec 2024 16:31:25 +0100 Subject: [PATCH 12/34] smartmeter: streamline SML/DLMS returned data, remove old code, cleanup --- smartmeter/__init__.py | 9 +- smartmeter/crc.py | 237 ------------------------------------ smartmeter/dlms.py | 70 +++++++++-- smartmeter/plugin.yaml | 64 ++-------- smartmeter/requirements.txt | 4 +- smartmeter/sml.py | 92 +++++--------- 6 files changed, 104 insertions(+), 372 deletions(-) delete mode 100755 smartmeter/crc.py diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index 7d4e2e554..4e9b5bea0 100755 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -33,7 +33,7 @@ # find out if we can import serial - if not, the plugin might not start anyway # serial is not needed in the plugin itself, but in the modules SML and DLMS, -# which will import the serial module by themselves +# which will import the serial module by themselves, if serial is configured try: import serial # noqa REQUIRED_PACKAGE_IMPORTED = True @@ -158,17 +158,10 @@ def load_parameters(self): self._config['dlms']['normalize'] = self.get_parameter_value('normalize') # SML only - # disabled parameters are for old frame parser self._config['sml'] = {} self._config['sml']['buffersize'] = self.get_parameter_value('buffersize') # 1024 self._config['sml']['device'] = self.get_parameter_value('device_type') self._config['sml']['date_offset'] = self.get_parameter_value('date_offset') # 0 - # self._config['sml']['poly'] = self.get_parameter_value('poly') # 0x1021 - # self._config['sml']['reflect_in'] = self.get_parameter_value('reflect_in') # True - # self._config['sml']['xor_in'] = self.get_parameter_value('xor_in') # 0xffff - # self._config['sml']['reflect_out'] = self.get_parameter_value('reflect_out') # True - # self._config['sml']['xor_out'] = self.get_parameter_value('xor_out') # 0xffff - # self._config['sml']['swap_crc_bytes'] = self.get_parameter_value('swap_crc_bytes') # False # # general plugin parameters diff --git a/smartmeter/crc.py b/smartmeter/crc.py deleted file mode 100755 index 3e2ecfe68..000000000 --- a/smartmeter/crc.py +++ /dev/null @@ -1,237 +0,0 @@ -# pycrc -- parameterisable CRC calculation utility and C source code generator -# -# Copyright (c) 2006-2017 Thomas Pircher -# -# 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. - - -""" -CRC algorithms implemented in Python. -If you want to study the Python implementation of the CRC routines, then this -is a good place to start from. - -The algorithms Bit by Bit, Bit by Bit Fast and Table-Driven are implemented. - -This module can also be used as a library from within Python. - -Examples -======== - -This is an example use of the different algorithms: - - from pycrc.algorithms import Crc - - crc = Crc(width = 16, poly = 0x8005, - reflect_in = True, xor_in = 0x0000, - reflect_out = True, xor_out = 0x0000) - print("{0:#x}".format(crc.bit_by_bit("123456789"))) - print("{0:#x}".format(crc.bit_by_bit_fast("123456789"))) - print("{0:#x}".format(crc.table_driven("123456789"))) -""" - - -class Crc(object): - """ - A base class for CRC routines. - - Default parameters are set to necessary CRC16 values for SML calculations - """ - # pylint: disable=too-many-instance-attributes - - def __init__( - self, - width: int = 16, - poly: int = 0x1021, - reflect_in: bool = True, - xor_in: int = 0xffff, - reflect_out: bool = True, - xor_out: int = 0xffff, - table_idx_width: int = 8, - slice_by: int = 1 - ): - """The Crc constructor. - - The parameters are as follows: - width - poly - reflect_in - xor_in - reflect_out - xor_out - """ - # pylint: disable=too-many-arguments - - self.width = width - self.poly = poly - self.reflect_in = reflect_in - self.xor_in = xor_in - self.reflect_out = reflect_out - self.xor_out = xor_out - self.tbl_idx_width = table_idx_width - self.slice_by = slice_by - - self.msb_mask = 0x1 << (self.width - 1) - self.mask = ((self.msb_mask - 1) << 1) | 1 - self.tbl_width = 1 << self.tbl_idx_width - - self.direct_init = self.xor_in - self.nondirect_init = self.__get_nondirect_init(self.xor_in) - if self.width < 8: - self.crc_shift = 8 - self.width - else: - self.crc_shift = 0 - - def __get_nondirect_init(self, init): - """ - return the non-direct init if the direct algorithm has been selected. - """ - crc = init - for dummy_i in range(self.width): - bit = crc & 0x01 - if bit: - crc ^= self.poly - crc >>= 1 - if bit: - crc |= self.msb_mask - return crc & self.mask - - def reflect(self, data, width): - """ - reflect a data word, i.e. reverts the bit order. - """ - # pylint: disable=no-self-use - - res = data & 0x01 - for dummy_i in range(width - 1): - data >>= 1 - res = (res << 1) | (data & 0x01) - return res - - def bit_by_bit(self, in_data): - """ - Classic simple and slow CRC implementation. This function iterates bit - by bit over the augmented input message and returns the calculated CRC - value at the end. - """ - # If the input data is a string, convert to bytes. - if isinstance(in_data, str): - in_data = bytearray(in_data, 'utf-8') - - reg = self.nondirect_init - for octet in in_data: - if self.reflect_in: - octet = self.reflect(octet, 8) - for i in range(8): - topbit = reg & self.msb_mask - reg = ((reg << 1) & self.mask) | ((octet >> (7 - i)) & 0x01) - if topbit: - reg ^= self.poly - - for i in range(self.width): - topbit = reg & self.msb_mask - reg = ((reg << 1) & self.mask) - if topbit: - reg ^= self.poly - - if self.reflect_out: - reg = self.reflect(reg, self.width) - return (reg ^ self.xor_out) & self.mask - - def bit_by_bit_fast(self, in_data): - """ - This is a slightly modified version of the bit-by-bit algorithm: it - does not need to loop over the augmented bits, i.e. the Width 0-bits - wich are appended to the input message in the bit-by-bit algorithm. - """ - # If the input data is a string, convert to bytes. - if isinstance(in_data, str): - in_data = bytearray(in_data, 'utf-8') - - reg = self.direct_init - for octet in in_data: - if self.reflect_in: - octet = self.reflect(octet, 8) - for i in range(8): - topbit = reg & self.msb_mask - if octet & (0x80 >> i): - topbit ^= self.msb_mask - reg <<= 1 - if topbit: - reg ^= self.poly - reg &= self.mask - if self.reflect_out: - reg = self.reflect(reg, self.width) - return reg ^ self.xor_out - - def gen_table(self): - """ - This function generates the CRC table used for the table_driven CRC - algorithm. The Python version cannot handle tables of an index width - other than 8. See the generated C code for tables with different sizes - instead. - """ - table_length = 1 << self.tbl_idx_width - tbl = [[0 for i in range(table_length)] for j in range(self.slice_by)] - for i in range(table_length): - reg = i - if self.reflect_in: - reg = self.reflect(reg, self.tbl_idx_width) - reg = reg << (self.width - self.tbl_idx_width + self.crc_shift) - for dummy_j in range(self.tbl_idx_width): - if reg & (self.msb_mask << self.crc_shift) != 0: - reg = (reg << 1) ^ (self.poly << self.crc_shift) - else: - reg = (reg << 1) - if self.reflect_in: - reg = self.reflect(reg >> self.crc_shift, self.width) << self.crc_shift - tbl[0][i] = (reg >> self.crc_shift) & self.mask - - for j in range(1, self.slice_by): - for i in range(table_length): - tbl[j][i] = (tbl[j - 1][i] >> 8) ^ tbl[0][tbl[j - 1][i] & 0xff] - return tbl - - def table_driven(self, in_data): - """ - The Standard table_driven CRC algorithm. - """ - # pylint: disable = line-too-long - - # If the input data is a string, convert to bytes. - if isinstance(in_data, str): - in_data = bytearray(in_data, 'utf-8') - - tbl = self.gen_table() - - if not self.reflect_in: - reg = self.direct_init << self.crc_shift - for octet in in_data: - tblidx = ((reg >> (self.width - self.tbl_idx_width + self.crc_shift)) ^ octet) & 0xff - reg = ((reg << (self.tbl_idx_width - self.crc_shift)) ^ (tbl[0][tblidx] << self.crc_shift)) & (self.mask << self.crc_shift) - reg = reg >> self.crc_shift - else: - reg = self.reflect(self.direct_init, self.width) - for octet in in_data: - tblidx = (reg ^ octet) & 0xff - reg = ((reg >> self.tbl_idx_width) ^ tbl[0][tblidx]) & self.mask - reg = self.reflect(reg, self.width) & self.mask - - if self.reflect_out: - reg = self.reflect(reg, self.width) - return reg ^ self.xor_out diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py index 98cc0dd77..6497cce08 100755 --- a/smartmeter/dlms.py +++ b/smartmeter/dlms.py @@ -35,7 +35,6 @@ import logging import time -from jinja2.ext import ExprStmtExtension import serial import socket # not needed, just for code portability @@ -75,6 +74,21 @@ LF = 0x0A # linefeed BCC = 0x00 # Block check Character will contain the checksum immediately following the data packet +OBIS_NAMES = { + **smlConst.OBIS_NAMES, + '010000020000': 'Firmware Version, Firmware Prüfsumme CRC, Datum', + '0100010800ff': 'Bezug Zählerstand Total', + '0100010801ff': 'Bezug Zählerstand Tarif 1', + '0100010802ff': 'Bezug Zählerstand Tarif 2', + '0100011100ff': 'Total-Zählerstand', + '0100020800ff': 'Einspeisung Zählerstand Total', + '0100020801ff': 'Einspeisung Zählerstand Tarif 1', + '0100020802ff': 'Einspeisung Zählerstand Tarif 2', + '0100600100ff': 'Server-ID', + '010060320101': 'Hersteller-Identifikation', + '0100605a0201': 'Prüfsumme', +} + # serial config S_BITS = serial.SEVENBITS S_PARITY = serial.PARITY_EVEN @@ -414,6 +428,24 @@ def check_protocol(data: bytes, only_listen=False, use_checksum=True) -> Union[s return res +def hex_obis(code: str) -> str: + """ convert obis to hex """ + + # form x.x.x.x.x.x from x-x:x.x.x*x + l1 = code.replace(':', '.').replace('-', '.').replace('*', '.').split('.') + # fill missing fields + if len(l1) in (3, 5): + l1 = l1 + ['255'] + if len(l1) == 4: + l1 = ['1', '0'] + l1 + + # fix for DLMS testing, codes from SmlLib all have pattern '1-0:x.y.z*255' + l1[1] = '0' + + # convert to string, take care for letters instead of numbers + return ''.join(['{:02x}'.format(x) for x in [int(y) if y.isnumeric() else ord(y) for y in (l1)]]) + + def parse(data: str, normalize: bool = True) -> dict: """ parse data returned from device read """ @@ -432,11 +464,10 @@ def parse(data: str, normalize: bool = True) -> dict: else: # ok, found some values to the right, lets isolate them values = arguments[1:] - obis_code = arguments[0] - - temp_values = values - values = [] - for s in temp_values: + code = arguments[0] + name = OBIS_NAMES.get(hex_obis(code)) + content = [] + for s in values: s = s.replace(')', '') if len(s) > 0: # we now should have a list with values that may contain a number @@ -453,17 +484,32 @@ def parse(data: str, normalize: bool = True) -> dict: # normalize SI units if possible to return values analogue to SML (e.g. Wh instead of kWh) v, u, uc = get_unit_code(v, u, normalize) + values = { + 'value': v, + 'valueRaw': v, + 'obis': code, + 'unit': u + } if uc: - values.append({'value': v, 'unit': u, 'unitCode': uc}) - else: - values.append({'value': v, 'unit': u}) + values['unitCode'] = uc + if name: + values['name'] = name + content.append(values) else: # just a value, no unit v = vu[0] - values.append({'value': v}) + values = { + 'value': v, + 'valueRaw': v, + 'obis': code + } + if name: + values['name'] = name + content.append(values) # uncomment the following line to check the generation of the values dictionary - logger.debug(f"{line:40} ---> {values}") - result[obis_code] = values + # logger.dbghigh(f"{line:40} ---> {content}") + result[code] = content + logger.debug(f"found {code} with {content}") logger.debug("finished processing lines") except Exception as e: logger.debug(f"error while extracting data: '{e}'") diff --git a/smartmeter/plugin.yaml b/smartmeter/plugin.yaml index 73deec5a3..4a26b43e2 100644 --- a/smartmeter/plugin.yaml +++ b/smartmeter/plugin.yaml @@ -129,46 +129,8 @@ parameters: description: de: 'Unix timestamp der Smartmeter Inbetriebnahme (nur SML)' en: 'Unix timestamp of Smartmeter start-up after installation (SML only)' - # the following parameters are for the old frame parser - # poly: - # type: int - # default: 0x1021 - # description: - # de: 'Polynom für die crc Berechnung (nur SML)' - # en: 'Polynomial for crc calculation (SML only)' - # reflect_in: - # type: bool - # default: true - # description: - # de: 'Umkehren der Bitreihenfolge für die Eingabe (nur SML)' - # en: 'Reflect the octets in the input (SML only)' - # xor_in: - # type: int - # default: 0xffff - # description: - # de: 'Initialer Wert für XOR Berechnung (nur SML)' - # en: 'Initial value for XOR calculation (SML only)' - # reflect_out: - # type: bool - # default: true - # description: - # de: 'Umkehren der Bitreihenfolge der Checksumme vor der Anwendung des XOR Wertes (nur SML)' - # en: 'Reflect the octet of checksum before application of XOR value (SML only)' - # xor_out: - # type: int - # default: 0xffff - # description: - # de: 'XOR Berechnung der CRC mit diesem Wert (nur SML)' - # en: 'XOR final CRC value with this value (SML only)' - # swap_crc_bytes: - # type: bool - # default: false - # description: - # de: 'Bytereihenfolge der berechneten Checksumme vor dem Vergleich mit der vorgegeben Checksumme tauschen (nur SML)' - # en: 'Swap bytes of calculated checksum prior to comparison with given checksum (SML only)' item_attributes: - # Definition of item attributes defined by this plugin obis_code: type: str description: @@ -197,19 +159,19 @@ item_attributes: - scaler # 10**x scaler for valueRaw - signature # meter signature - status # status bit field - - statRun # meter is running - - statFraudMagnet # fraud magnet activated - - statFraudCover # fraud cover triggered - - statEnergyTotal # - - statEnergyL1 # - - statEnergyL2 # - - statEnergyL3 # - - statRotaryField # - - statBackstop # - - statCalFault # - - statVoltageL1 # - - statVoltageL2 # - - statVoltageL3 # + - statRun # meter is counting + - statFraudMagnet # magnetic manipulation detected + - statFraudCover # cover manipulation detected + - statEnergyTotal # Current flow total. True: -A, False: +A + - statEnergyL1 # Current flow L1. True: -A, False: +A + - statEnergyL2 # Current flow L2. True: -A, False: +A + - statEnergyL3 # Current flow L3. True: -A, False: +A + - statRotaryField # rotary field faulty = not L1->L2->L3 + - statBackstop # backstop active + - statCalFault # calibration relevant fatal fault + - statVoltageL1 # Voltage L1 present + - statVoltageL2 # Voltage L2 present + - statVoltageL3 # Voltage L3 present description: de: > Eigenschaft des gelesenen Wertes: diff --git a/smartmeter/requirements.txt b/smartmeter/requirements.txt index 9fcf19fe7..0514e3e12 100644 --- a/smartmeter/requirements.txt +++ b/smartmeter/requirements.txt @@ -1,2 +1,2 @@ -pyserial -smllib +pyserial>=3.2.1 +SmlLib>=1.3 diff --git a/smartmeter/sml.py b/smartmeter/sml.py index 23e0c681a..3562e8278 100755 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -359,52 +359,52 @@ def parse_frame(self, frame): if code not in self.result: self.result[code] = [] content = { + 'obis': code, 'value': entry.get_value(), - 'name': OBIS_NAMES.get(entry.obis), - 'valueRaw': entry.get_value() + 'valueRaw': entry.get_value(), + 'name': OBIS_NAMES.get(entry.obis) } if entry.scaler: content['scaler'] = entry.scaler content['value'] = round(content['value'] * 10 ** content['scaler'], 1) - if entry.status: - content['status'] = entry.status + if entry.unit: + content['unit'] = smlConst.UNITS.get(entry.unit) + content['unitCode'] = entry.unit if entry.val_time: content['valTime'] = entry.val_time content['actTime'] = time.ctime(config.get('date_offset', 0) + entry.val_time) if entry.value_signature: content['signature'] = entry.value_signature - if entry.unit: - content['unit'] = smlConst.UNITS.get(entry.unit) - content['unitCode'] = entry.unit - - # Decoding status information if present - if 'status' in content: + if entry.status: + content['status'] = entry.status + # Decoding status information if present # for bitwise operation, true-ish result means bit is set - content['statRun'] = bool((content['status'] >> 8) & 1) # True: meter is counting, False: standstill - content['statFraudMagnet'] = bool((content['status'] >> 8) & 2) # True: magnetic manipulation detected, False: ok - content['statFraudCover'] = bool((content['status'] >> 8) & 4) # True: cover manipulation detected, False: ok - content['statEnergyTotal'] = bool((content['status'] >> 8) & 8) # Current flow total. True: -A, False: +A - content['statEnergyL1'] = bool((content['status'] >> 8) & 16) # Current flow L1. True: -A, False: +A - content['statEnergyL2'] = bool((content['status'] >> 8) & 32) # Current flow L2. True: -A, False: +A - content['statEnergyL3'] = bool((content['status'] >> 8) & 64) # Current flow L3. True: -A, False: +A - content['statRotaryField'] = bool((content['status'] >> 8) & 128) # True: rotary field not L1->L2->L3, False: ok - content['statBackstop'] = bool((content['status'] >> 8) & 256) # True: backstop active, False: backstop not active - content['statCalFault'] = bool((content['status'] >> 8) & 512) # True: calibration relevant fatal fault, False: ok - content['statVoltageL1'] = bool((content['status'] >> 8) & 1024) # True: Voltage L1 present, False: not present - content['statVoltageL2'] = bool((content['status'] >> 8) & 2048) # True: Voltage L2 present, False: not present - content['statVoltageL3'] = bool((content['status'] >> 8) & 4096) # True: Voltage L3 present, False: not present - - # TODO: for backward compatibility - check if really needed - content['obis'] = code + try: + content['statRun'] = bool((content['status'] >> 8) & 1) # True: meter is counting, False: standstill + content['statFraudMagnet'] = bool((content['status'] >> 8) & 2) # True: magnetic manipulation detected, False: ok + content['statFraudCover'] = bool((content['status'] >> 8) & 4) # True: cover manipulation detected, False: ok + content['statEnergyTotal'] = bool((content['status'] >> 8) & 8) # Current flow total. True: -A, False: +A + content['statEnergyL1'] = bool((content['status'] >> 8) & 16) # Current flow L1. True: -A, False: +A + content['statEnergyL2'] = bool((content['status'] >> 8) & 32) # Current flow L2. True: -A, False: +A + content['statEnergyL3'] = bool((content['status'] >> 8) & 64) # Current flow L3. True: -A, False: +A + content['statRotaryField'] = bool((content['status'] >> 8) & 128) # True: rotary field not L1->L2->L3, False: ok + content['statBackstop'] = bool((content['status'] >> 8) & 256) # True: backstop active, False: backstop not active + content['statCalFault'] = bool((content['status'] >> 8) & 512) # True: calibration relevant fatal fault, False: ok + content['statVoltageL1'] = bool((content['status'] >> 8) & 1024) # True: Voltage L1 present, False: not present + content['statVoltageL2'] = bool((content['status'] >> 8) & 2048) # True: Voltage L2 present, False: not present + content['statVoltageL3'] = bool((content['status'] >> 8) & 4096) # True: Voltage L3 present, False: not present + except Exception: + pass + # Convert some special OBIS values into nicer format # EMH ED300L: add additional OBIS codes - if content['obis'] == '1-0:0.2.0*0': + if code == '1-0:0.2.0*0': content['value'] = content['valueRaw'].decode() # Firmware as UTF-8 string - if content['obis'] == '1-0:96.50.1*1' or content['obis'] == '129-129:199.130.3*255': + if code in ('1-0:96.50.1*1', '129-129:199.130.3*255'): content['value'] = content['valueRaw'].decode() # Manufacturer code as UTF-8 string - if content['obis'] == '1-0:96.1.0*255' or content['obis'] == '1-0:0.0.9*255': + if code in ('1-0:96.1.0*255', '1-0:0.0.9*255'): content['value'] = to_hex(content['valueRaw']) - if content['obis'] == '1-0:96.5.0*255': + if code == '1-0:96.5.0*255': content['value'] = bin(content['valueRaw'] >> 8) # Status as binary string, so not decoded into status bits as above # end TODO @@ -445,38 +445,6 @@ def parse(self, data: bytes) -> dict: return self.fp() - # old frame parser, possibly remove later (needs add'l helper and not-presend "parse" routine) - # if START_SEQUENCE in data: - # prev, _, data = data.partition(START_SEQUENCE) - # logger.debug(f'start sequence marker {to_hex(START_SEQUENCE)} found') - # if END_SEQUENCE in data: - # data, _, remainder = data.partition(END_SEQUENCE) - # logger.debug(f'end sequence marker {to_hex(END_SEQUENCE)} found') - # logger.debug(f'packet size is {len(data)}') - # if len(remainder) > 3: - # filler = remainder[0] - # logger.debug(f'{filler} fill byte(s) ') - # checksum = int.from_bytes(remainder[1:3], byteorder='little') - # logger.debug(f'Checksum is {to_hex(checksum)}') - # buffer = bytearray() - # buffer += START_SEQUENCE + data + END_SEQUENCE + remainder[0:1] - # logger.debug(f'Buffer length is {len(buffer)}') - # logger.debug(f'buffer is: {to_hex(buffer)}') - # crc16 = Crc(width=16, poly=poly, reflect_in=reflect_in, xor_in=xor_in, reflect_out=reflect_out, xor_out=xor_out) - # crc_calculated = crc16.table_driven(buffer) - # if swap_crc_bytes: - # logger.debug(f'calculated swapped checksum is {to_hex(swap16(crc_calculated))}, given CRC is {to_hex(checksum)}') - # crc_calculated = swap16(crc_calculated) - # else: - # logger.debug(f'calculated checksum is {to_hex(crc_calculated)}, given CRC is {to_hex(checksum)}') - # data_is_valid = crc_calculated == checksum - # else: - # logger.debug('not enough bytes read at end to satisfy checksum calculation') - # else: - # logger.debug('no End sequence marker found in data') - # else: - # logger.debug('no Start sequence marker found in data') - def query(config) -> dict: """ From f98132f04f99f15000af9dc953a5af521de6872e Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Sun, 8 Dec 2024 14:12:45 +0100 Subject: [PATCH 13/34] smartmeter: remove special handling no longer necessary --- smartmeter/__init__.py | 12 +++++++++++- smartmeter/sml.py | 17 ++++++----------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index 4e9b5bea0..667df1b33 100755 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -28,6 +28,7 @@ __revision__ = '0.1' __docformat__ = 'reStructuredText' +from inspect import Attribute import threading import sys @@ -326,8 +327,17 @@ def _update_values(self, result: dict): for item in items: conf = self.get_item_config(item) index = conf.get('index', 0) + # new default: if we don't find prop, we don't change the respective item - itemValue = vlist[index].get(prop) + itemValue = None + try: + itemValue = vlist[index].get(prop) + except IndexError: + self.logger.warning(f'data {vlist} doesn\'t have {index} elements. Check index setting...') + continue + except AttributeError: + self.logger.warning(f'got empty result for {obis}, something went wrong.') + # skip item assignment to save time and cpu cycles if itemValue is not None: try: diff --git a/smartmeter/sml.py b/smartmeter/sml.py index 3562e8278..ddf9446f6 100755 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -339,7 +339,8 @@ def get_sock(self): class SmlFrameParser(): - def __init__(self): + def __init__(self, config: dict): + self.config = config self.result = {} def __call__(self, frame=None): @@ -372,7 +373,7 @@ def parse_frame(self, frame): content['unitCode'] = entry.unit if entry.val_time: content['valTime'] = entry.val_time - content['actTime'] = time.ctime(config.get('date_offset', 0) + entry.val_time) + content['actTime'] = time.ctime(self.config.get('date_offset', 0) + entry.val_time) if entry.value_signature: content['signature'] = entry.value_signature if entry.status: @@ -398,12 +399,6 @@ def parse_frame(self, frame): # Convert some special OBIS values into nicer format # EMH ED300L: add additional OBIS codes - if code == '1-0:0.2.0*0': - content['value'] = content['valueRaw'].decode() # Firmware as UTF-8 string - if code in ('1-0:96.50.1*1', '129-129:199.130.3*255'): - content['value'] = content['valueRaw'].decode() # Manufacturer code as UTF-8 string - if code in ('1-0:96.1.0*255', '1-0:0.0.9*255'): - content['value'] = to_hex(content['valueRaw']) if code == '1-0:96.5.0*255': content['value'] = bin(content['valueRaw'] >> 8) # Status as binary string, so not decoded into status bits as above # end TODO @@ -418,8 +413,8 @@ def parse_frame(self, frame): class SmlParser(): - def __init__(self): - self.fp = SmlFrameParser() + def __init__(self, config: dict): + self.fp = SmlFrameParser(config) def __call__(self, data: bytes) -> dict: return self.parse(data) @@ -493,7 +488,7 @@ def query(config) -> dict: # parse data # - parser = SmlParser() + parser = SmlParser(config) return parser(result) From a4d18b56543eb922ed276daf9d24c253b782f81c Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Mon, 9 Dec 2024 18:07:16 +0100 Subject: [PATCH 14/34] smartmeter: cleanup item handling --- smartmeter/__init__.py | 86 +++++++++++++++++++----------------------- smartmeter/sml.py | 9 ++--- 2 files changed, 41 insertions(+), 54 deletions(-) diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index 667df1b33..fce076b70 100755 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -45,7 +45,7 @@ from lib.item.item import Item from lib.shtime import Shtime from collections.abc import Callable -from typing import Union +from typing import (Union, Any) from . import dlms from . import sml @@ -73,6 +73,9 @@ 'statRotaryField', 'statBackstop', 'statCalFault', 'statVoltageL1', 'statVoltageL2', 'statVoltageL3', 'obis' ] +# mapping separator. set to something not probable to be in obis, index or prop +SEP = '-#-' + class Smartmeter(SmartPlugin, Conversion): """ @@ -233,6 +236,9 @@ def stop(self): except Exception: pass + def to_mapping(self, obis: str, index: Any) -> str: + return f'{obis}{SEP}{index}' + def parse_item(self, item: Item) -> Union[Callable, None]: """ Default plugin parse_item method. Is called when the plugin is initialized. @@ -246,21 +252,20 @@ def parse_item(self, item: Item) -> Union[Callable, None]: if prop not in PROPS: self.logger.warning(f'item {item}: invalid property {prop} requested for obis {obis}, setting default "value"') prop = 'value' - index = self.get_iattr_value(item.conf, OBIS_INDEX, default=0) vtype = self.get_iattr_value(item.conf, OBIS_VTYPE, default='') - if vtype in ('int', 'num', 'float', 'str'): - if vtype != item.type(): - self.logger.warning(f'item {item}: item type is {item.type()}, but obis_vtype is {vtype}, please fix item definition') - - self.add_item(item, {'property': prop, 'index': index, 'vtype': vtype}, obis) + if vtype: + if prop.startswith('value'): + if vtype in ('int', 'num', 'float', 'str') and vtype != item.type(): + self.logger.warning(f'item {item}: item type is {item.type()}, but obis_vtype is "{vtype}", please fix item definition') + vtype = None + else: + self.logger.warning(f'item {item} has obis_vtype set, which is only valid for "value" property, not "{prop}", ignoring.') + vtype = None + index = self.get_iattr_value(item.conf, OBIS_INDEX, default=0) - if obis not in self._items: - self._items[obis] = {} - if prop not in self._items[obis]: - self._items[obis][prop] = [] - self._items[obis][prop].append(item) + self.add_item(item, {'property': prop, 'index': index, 'vtype': vtype}, self.to_mapping(obis, index)) self.obis_codes.append(obis) - self.logger.debug(f'Attach {item.property.path} with obis={obis} and prop={prop}') + self.logger.debug(f'Attach {item.property.path} with obis={obis}, prop={prop} and index={index}') if self.has_iattr(item.conf, OBIS_READOUT): self.add_item(item) @@ -286,14 +291,6 @@ def poll_device(self): if self._lock.acquire(blocking=False): self.logger.debug('lock acquired') try: - # - # module.query needs to return a dict: - # { - # 'readout': '', (only for dlms?) - # 'obis1': [{'value': val0, optional 'unit': unit1}, {'value': val1, optional 'unit': unit1'}] - # 'obis2': [{...}] - # } - # result = self._get_module().query(self._config) if not result: self.logger.warning('no results from smartmeter query received') @@ -314,47 +311,40 @@ def _update_values(self, result: dict): :param Code: OBIS Code :param Values: list of dictionaries with Value / Unit entries """ + # self.logger.debug(f'running _update_values with {result}') if 'readout' in result: for item in self._readout_items: item(result['readout'], self.get_fullname()) self.logger.debug(f'set item {item} to readout {result["readout"]}') del result['readout'] + # check all obis codes for obis, vlist in result.items(): - if obis in self._items: - entry = self._items[obis] - for prop, items in entry.items(): - for item in items: - conf = self.get_item_config(item) - index = conf.get('index', 0) - - # new default: if we don't find prop, we don't change the respective item - itemValue = None + if not self._is_obis_code_wanted(obis): + continue + for idx, vdict in enumerate(vlist): + for item in self.get_items_for_mapping(self.to_mapping(obis, idx)): + conf = self.get_item_config(item) + # self.logger.debug(f'processing item {item} with {conf} for index {idx}...') + if conf.get('index', 0) == idx: + prop = conf.get('property', 'value') + val = None try: - itemValue = vlist[index].get(prop) - except IndexError: - self.logger.warning(f'data {vlist} doesn\'t have {index} elements. Check index setting...') + val = vdict[prop] + except KeyError: + self.logger.warning(f'item {item} wants property {prop} which has not been recceived') continue - except AttributeError: - self.logger.warning(f'got empty result for {obis}, something went wrong.') - # skip item assignment to save time and cpu cycles - if itemValue is not None: + # skip processing if val is None, save cpu cycles + if val is not None: try: - val = vlist[index][prop] converter = conf['vtype'] itemValue = self._convert_value(val, converter) # self.logger.debug(f'conversion yielded {itemValue} from {val} for converter "{converter}"') - except IndexError: - self.logger.warning(f'value for index {index} not found in {vlist["value"]}, skipping...') - continue - except KeyError as e: - self.logger.warning(f'key error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') - except NameError as e: - self.logger.warning(f'name error while setting item {item} for obis code {obis} to value "{itemValue}": {e}') - - item(itemValue, self.get_fullname()) - self.logger.debug(f'set item {item} for obis code {obis}:{prop} to value "{itemValue}"') + item(itemValue, self.get_fullname()) + self.logger.debug(f'set item {item} for obis code {obis}:{prop} to value {itemValue}') + except ValueError as e: + self.logger.error(f'error while converting value {val} for item {item}, obis code {obis}: {e}') else: self.logger.debug(f'for item {item} and obis code {obis}:{prop} no content was received') diff --git a/smartmeter/sml.py b/smartmeter/sml.py index ddf9446f6..3e1eafb79 100755 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -97,7 +97,7 @@ from sml_test import RESULT else: from .sml_test import RESULT - logger.error('SML testing mode enabled, no serial communication, no real results!') + logger.error(f'SML testing mode enabled, no serial communication, no real results! Dataset is {len(RESULT)} bytes long.') else: RESULT = b'' @@ -151,6 +151,7 @@ def format_time(timedelta): class SmlReader(): def __init__(self, logger, config: dict): + print('init') self.config = config self.sock = None self.logger = logger @@ -174,14 +175,12 @@ def __call__(self) -> bytes: # # open the serial communication # - locked = self.lock.acquire(blocking=False) if not locked: logger.error('could not get lock for serial/network access. Is another scheduled/manual action still active?') return b'' try: # lock release - runtime = time.time() self.get_sock() if not self.sock: @@ -192,7 +191,6 @@ def __call__(self) -> bytes: # # read data from device # - response = bytes() try: response = self.read() @@ -218,7 +216,6 @@ def __call__(self) -> bytes: pass self.sock = None self.lock.release() - return response def _read(self) -> bytes: @@ -272,7 +269,7 @@ def get_sock(self): """ open serial or network socket """ if TESTING: - self.sock = None + self.sock = 1 self.target = '(test input)' return From 6c581a2283f5d8ebf22846647c3825df74b89f42 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Mon, 9 Dec 2024 22:13:55 +0100 Subject: [PATCH 15/34] smartmeter: refactor for webif api access --- smartmeter/__init__.py | 211 +++++++++++++++++++++++++---------------- 1 file changed, 127 insertions(+), 84 deletions(-) diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index fce076b70..f95ea8b19 100755 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -28,7 +28,6 @@ __revision__ = '0.1' __docformat__ = 'reStructuredText' -from inspect import Attribute import threading import sys @@ -93,27 +92,129 @@ def __init__(self, sh): # Call init code of parent class (SmartPlugin) super().__init__() + self.connected = False + self.alive = False + self._lock = threading.Lock() + + # store "wanted" obis codes + self.obis_codes = [] + + # store last response(s) + self.obis_results = {} + + # set or discovered protocol (SML/DLMS) + self.protocol = None + + # protocol auto-detected? + self.proto_detected = False + # load parameters from config - self._protocol = None - self._proto_detect = False - self.load_parameters() + self._load_parameters() # quit if errors on parameter read if not self._init_complete: return - self.connected = False - self.alive = False + # self.init_webinterface(WebInterface) - self._items = {} # all items by obis code by obis prop - self._readout_items = [] # all readout items - self.obis_codes = [] + def discover(self, protocol=None) -> bool: + """ + try to identify protocol of smartmeter - self._lock = threading.Lock() + if protocol is given, only test this protocol + otherwise, test DLMS and SML + """ + if not protocol: + disc_protos = ['DLMS', 'SML'] + else: + disc_protos = [protocol] - # self.init_webinterface(WebInterface) + for proto in disc_protos: + if self._get_module(proto).discover(self._config): + self.protocol = proto + if len(disc_protos) > 1: + self.proto_detected = True + return True + + return False + + def query(self, assign_values: bool = True, protocol=None) -> dict: + """ + query smartmeter resp. listen for data + + if protocol is given, try to use the given protocol + otherwise, use self._protocol as default + + if assign_values is set, assign received values to items + if assign_values is not set, just return the results + """ + if not protocol: + protocol = self.protocol + ref = self._get_module(protocol) - def load_parameters(self): + result = {} + if self._lock.acquire(blocking=False): + self.logger.debug('lock acquired') + try: + result = ref.query(self._config) + if not result: + self.logger.warning('no results from smartmeter query received') + else: + self.logger.debug(f'got result: {result}') + if assign_values: + self._update_values(result) + except Exception as e: + self.logger.error(f'error: {e}', exc_info=True) + finally: + self._lock.release() + self.logger.debug('lock released') + else: + self.logger.warning('device query is alrady running. Check connection and/or use longer query interval time.') + + return result + + def run(self): + """ + Run method for the plugin + """ + self.logger.debug('run method called') + + # TODO: reload parameters - why? + self._load_parameters() + + if not self.protocol: + self.discover() + + self.alive = True + if self.protocol: + self.logger.info(f'{"detected" if self.proto_detected else "set"} protocol {self.protocol}') + else: + # skip cycle / crontab scheduler if no protocol set (only manual control from web interface) + self.logger.error('unable to auto-detect device protocol (SML/DLMS). Try manual disconvery via standalone mode or Web Interface.') + return + + # Setup scheduler for device poll loop, if protocol set + if (self.cycle or self.crontab) and self.protocol: + if self.crontab: + next = None # adhere to the crontab + else: + # no crontab given so we might just query immediately + next = shtime.now() + self.scheduler_add(self.get_fullname(), self.poll_device, prio=5, cycle=self.cycle, cron=self.crontab, next=next) + self.logger.debug('run method finished') + + def stop(self): + """ + Stop method for the plugin + """ + self.logger.debug('stop method called') + self.alive = False + try: + self.scheduler_remove(self.get_fullname()) + except Exception: + pass + + def _load_parameters(self): # # connection configuration @@ -150,7 +251,8 @@ def load_parameters(self): # get mode (SML/DLMS) if set by user # if not set, try to get at runtime - self._protocol = self.get_parameter_value('protocol').upper() + if not self.protocol: + self.protocol = self.get_parameter_value('protocol').upper() # DLMS only self._config['dlms'] = {} @@ -181,62 +283,17 @@ def load_parameters(self): if not (self.cycle or self.crontab): self.logger.warning(f'{self.get_fullname()}: no update cycle or crontab set. The smartmeter will not be queried automatically') - def _get_module(self): + def _get_module(self, protocol=None): """ return module reference for SML/DMLS module """ - name = __name__ + '.' + str(self._protocol).lower() + if not protocol: + protocol = self.protocol + name = __name__ + '.' + str(protocol).lower() ref = sys.modules.get(name) if not ref: self.logger.warning(f"couldn't get reference for module {name}...") return ref - def run(self): - """ - Run method for the plugin - """ - self.logger.debug('run method called') - - # TODO: reload parameters - why? - self.load_parameters() - - if not self._protocol: - # TODO: call DLMS/SML discovery routines to find protocol - if sml.discover(self._config): - self._protocol = 'SML' - self._proto_detect = True - elif dlms.discover(self._config): - self._protocol = 'DLMS' - self._proto_detect = True - - self.alive = True - if self._protocol: - self.logger.info(f'{"detected" if self._proto_detect else "set"} protocol {self._protocol}') - else: - self.logger.error('unable to auto-detect device protocol (SML/DLMS). Try manual disconvery via standalone mode or Web Interface.') - # skip cycle / crontab scheduler if no protocol set (only manual control from web interface) - return - - # Setup scheduler for device poll loop, if protocol set - if (self.cycle or self.crontab) and self._protocol: - if self.crontab: - next = None # adhere to the crontab - else: - # no crontab given so we might just query immediately - next = shtime.now() - self.scheduler_add(self.get_fullname(), self.poll_device, prio=5, cycle=self.cycle, cron=self.crontab, next=next) - self.logger.debug('run method finished') - - def stop(self): - """ - Stop method for the plugin - """ - self.logger.debug('stop method called') - self.alive = False - try: - self.scheduler_remove(self.get_fullname()) - except Exception: - pass - - def to_mapping(self, obis: str, index: Any) -> str: + def _to_mapping(self, obis: str, index: Any) -> str: return f'{obis}{SEP}{index}' def parse_item(self, item: Item) -> Union[Callable, None]: @@ -263,13 +320,12 @@ def parse_item(self, item: Item) -> Union[Callable, None]: vtype = None index = self.get_iattr_value(item.conf, OBIS_INDEX, default=0) - self.add_item(item, {'property': prop, 'index': index, 'vtype': vtype}, self.to_mapping(obis, index)) + self.add_item(item, {'property': prop, 'index': index, 'vtype': vtype}, self._to_mapping(obis, index)) self.obis_codes.append(obis) self.logger.debug(f'Attach {item.property.path} with obis={obis}, prop={prop} and index={index}') if self.has_iattr(item.conf, OBIS_READOUT): - self.add_item(item) - self._readout_items.append(item) + self.add_item(item, mapping='readout') self.logger.debug(f'Attach {item.property.path} for readout') def _is_obis_code_wanted(self, code: str) -> bool: @@ -288,22 +344,7 @@ def poll_device(self): if not self._get_module(): return - if self._lock.acquire(blocking=False): - self.logger.debug('lock acquired') - try: - result = self._get_module().query(self._config) - if not result: - self.logger.warning('no results from smartmeter query received') - else: - self.logger.debug(f'got result: {result}') - self._update_values(result) - except Exception as e: - self.logger.error(f'error: {e}', exc_info=True) - finally: - self._lock.release() - self.logger.debug('lock released') - else: - self.logger.warning('device query is alrady running. Check connection and/or use longer query interval time.') + self.query() def _update_values(self, result: dict): """ @@ -312,8 +353,10 @@ def _update_values(self, result: dict): :param Values: list of dictionaries with Value / Unit entries """ # self.logger.debug(f'running _update_values with {result}') + self.obis_results.update(result) + if 'readout' in result: - for item in self._readout_items: + for item in self.get_items_for_mapping('readout'): item(result['readout'], self.get_fullname()) self.logger.debug(f'set item {item} to readout {result["readout"]}') del result['readout'] @@ -323,7 +366,7 @@ def _update_values(self, result: dict): if not self._is_obis_code_wanted(obis): continue for idx, vdict in enumerate(vlist): - for item in self.get_items_for_mapping(self.to_mapping(obis, idx)): + for item in self.get_items_for_mapping(self._to_mapping(obis, idx)): conf = self.get_item_config(item) # self.logger.debug(f'processing item {item} with {conf} for index {idx}...') if conf.get('index', 0) == idx: From 11a39c0a73863cc00d8bd9386eb032f744d5b229 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Thu, 12 Dec 2024 21:52:04 +0100 Subject: [PATCH 16/34] smartmeter: initial web interface --- smartmeter/__init__.py | 6 +- smartmeter/webif/__init__.py | 121 ++++++++++++ smartmeter/webif/index.html | 355 +++++++++++++++++++++++++++++++++++ 3 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 smartmeter/webif/__init__.py create mode 100644 smartmeter/webif/index.html diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index f95ea8b19..e898ab280 100755 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -46,8 +46,8 @@ from collections.abc import Callable from typing import (Union, Any) -from . import dlms -from . import sml +from . import dlms # noqa +from . import sml # noqa from .conversion import Conversion try: from .webif import WebInterface @@ -115,7 +115,7 @@ def __init__(self, sh): if not self._init_complete: return - # self.init_webinterface(WebInterface) + self.init_webinterface(WebInterface) def discover(self, protocol=None) -> bool: """ diff --git a/smartmeter/webif/__init__.py b/smartmeter/webif/__init__.py new file mode 100644 index 000000000..c530db5b1 --- /dev/null +++ b/smartmeter/webif/__init__.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2024- Michael Wenzel wenzel_michael@web.de +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# This file implements the web interface for the Sample plugin. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import json +import cherrypy + +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after being rendered + """ + pagelength = self.plugin.get_parameter_value('webif_pagelength') + tmpl = self.tplenv.get_template('index.html') + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=self.plugin.get_item_list(), + item_count=len(self.plugin.get_item_list())) + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + # if dataSets are used, define them here + if dataSet == 'overview': + # get the new data from the plugin variable called _webdata + + data = {} + for obis, value in self.plugin.obis_results.items(): + if isinstance(value, list): + value = value[0] + data[obis] = value + + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html overview exception: {e}") + + elif dataSet == 'devices_info': + data = {'items': {}} + + # add item data + for item in self.plugin.get_item_list(): + item_dict = {'value': item.property.value, + 'last_update': item.property.last_update.strftime('%d.%m.%Y %H:%M:%S'), + 'last_change': item.property.last_change.strftime('%d.%m.%Y %H:%M:%S')} + + data['items'][item.property.path] = item_dict + + # add obis result + data['obis_results'] = self.plugin.obis_results + + try: + return json.dumps(data, default=str) + except Exception as e: + self.logger.error(f"get_data_html devices_info exception: {e}") + + if dataSet is None: + return + + @cherrypy.expose + def read_data(self): + self.plugin.query(assign_values=False) diff --git a/smartmeter/webif/index.html b/smartmeter/webif/index.html new file mode 100644 index 000000000..b19ab812b --- /dev/null +++ b/smartmeter/webif/index.html @@ -0,0 +1,355 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + + +{% set update_interval = 10000 %} + + +{% set dataSet = 'devices_info' %} + + +{% set update_params = item_id %} + + +{% set buttons = true %} + + +{% set autorefresh_buttons = true %} + + +{% set reload_button = true %} + + +{% set close_button = true %} + + +{% set row_count = true %} + + +{% set initial_update = true %} + + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + +{% block pluginscripts %} + + + +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Connection{% if p._config['host'] %}{{ p._config['host'] }}{{ _(':') }}{{ p._config['port'] }}{% else %}{{ p._config['serial_port'] }}{% endif %}Timeout{{ p._config['timeout'] }}s
Protokoll{{ p.protocol }}Baudrate{{ p._config['baudrate'] }}
Verbunden{% if p.connected %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}Abfrageintervall{% if p.cycle %}{{ p.cycle }}s{% endif %}{% if p.crontab %}{{ p.crontab }}{% endif %}
+{% endblock headtable %} + + +{% block buttons %} +
+ +
+{% endblock %} + + +{% set tabcount = 3 %} + + +{% if item_count==0 %} + {% set start_tab = 2 %} +{% endif %} + + +{% set tab1title = "" ~ p.get_shortname() ~ " Items (" ~ item_count ~ ")" %} +{% block bodytab1 %} + + + {% for item in items %} + + + + + + + + + + + + + {% endfor %} + +
{{ item.property.path }}{{ item._type }}{{ item.conf['obis_code'] }}{{ item.conf['obis_index'] }}{{ item.conf['obis_property'] }}{{ item.conf['obis_vtype'] }}{{ item.property.value }}  
+{% endblock bodytab1 %} + + + +{% set tab2title = "" "OBIS Data (" ~ len(p.obis_results) ~ ")" %} +{% block bodytab2 %} + + + {% for entry in p.obis_results %} + + + + + + {% endfor %} + +
{{ entry }}{{ p.obis_results[entry][0] }}
+{% endblock bodytab2 %} + + + +{% set tab3title = "" "Zählerstatus" %} +{% block bodytab3 %} + + + {% for key, value in p.obis_results['1-0:1.8.0*255'][0].items() %} + {% if key in ['statRun', 'statFraudMagnet', 'statFraudCover', 'statEnergyTotal', 'statEnergyL1', 'statEnergyL2', 'statEnergyL3', 'statVoltageL1', 'statVoltageL2', 'statVoltageL3', 'statRotaryField', 'statBackstop', 'statCalFault'] %} + + + + + + {% endif %} + {% endfor %} + +
+ {% if key == 'statRun' %} + {{ _('Zähler in Betrieb') }} + {% elif key == 'statFraudMagnet' %} + {{ _('magnetische Manipulation') }} + {% elif key == 'statFraudCover' %} + {{ _('Manipulation der Abdeckung') }} + {% elif key == 'statEnergyTotal' %} + {{ _('Stromfluss gesamt') }} + {% elif key == 'statEnergyL1' %} + {{ _('Stromfluss L1') }} + {% elif key == 'statEnergyL2' %} + {{ _('Stromfluss L2') }} + {% elif key == 'statEnergyL3' %} + {{ _('Stromfluss L3') }} + {% elif key == 'statVoltageL1' %} + {{ _('Spannung an L1') }} + {% elif key == 'statVoltageL2' %} + {{ _('Spannung an L2') }} + {% elif key == 'statVoltageL3' %} + {{ _('Spannung an L3') }} + {% elif key == 'statRotaryField' %} + {{ _('Drehfeld') }} + {% elif key == 'statBackstop' %} + {{ _('Backstop') }} + {% elif key == 'statCalFault' %} + {{ _('Fataler Fehler') }} + {% endif %} + + {% if key in ['statRun', 'statFraudMagnet', 'statFraudCover'] %} + {% if value %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %} + {% elif key in ['statEnergyTotal', 'statEnergyL1', 'statEnergyL2', 'statEnergyL3'] %} + {% if value %}{{ _('-A') }}{% else %}{{ _('+A') }}{% endif %} + {% elif key in ['statVoltageL1', 'statVoltageL2', 'statVoltageL3'] %} + {% if value %}{{ _('OK') }}{% else %}{{ _('NOK') }}{% endif %} + {% elif key == 'statRotaryField' %} + {% if value %}{{ _('NOK') }}{% else %}{{ _('OK') }}{% endif %} + {% elif key == 'statBackstop' %} + {% if value %}{{ _('Active') }}{% else %}{{ _('Nein') }}{% endif %} + {% elif key == 'statCalFault' %} + {% if value %}{{ _('FAULT') }}{% else %}{{ _('Keiner') }}{% endif %} + {% endif %} +
+{% endblock bodytab3 %} From 355303fcd4a3a4ca6c0c6d93477d4b465fa53fd0 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Fri, 13 Dec 2024 13:23:46 +0100 Subject: [PATCH 17/34] smartmeter: add asyncio feature for SML --- smartmeter/__init__.py | 84 +++++++++++++++++++----- smartmeter/plugin.yaml | 12 ++++ smartmeter/sml.py | 144 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 220 insertions(+), 20 deletions(-) diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index e898ab280..40ac78b3b 100755 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -28,6 +28,7 @@ __revision__ = '0.1' __docformat__ = 'reStructuredText' +import asyncio import threading import sys @@ -49,10 +50,7 @@ from . import dlms # noqa from . import sml # noqa from .conversion import Conversion -try: - from .webif import WebInterface -except ImportError: - pass +from .webif import WebInterface shtime = Shtime.get_instance() @@ -93,6 +91,7 @@ def __init__(self, sh): super().__init__() self.connected = False + self._autoreconnect = self.get_parameter_value('autoreconnect') self.alive = False self._lock = threading.Lock() @@ -108,6 +107,9 @@ def __init__(self, sh): # protocol auto-detected? self.proto_detected = False + self.async_connected = False + self.use_asyncio = False + # load parameters from config self._load_parameters() @@ -194,13 +196,16 @@ def run(self): return # Setup scheduler for device poll loop, if protocol set - if (self.cycle or self.crontab) and self.protocol: - if self.crontab: - next = None # adhere to the crontab - else: - # no crontab given so we might just query immediately - next = shtime.now() - self.scheduler_add(self.get_fullname(), self.poll_device, prio=5, cycle=self.cycle, cron=self.crontab, next=next) + if self.use_asyncio: + self.start_asyncio(self.plugin_coro()) + else: + if (self.cycle or self.crontab) and self.protocol: + if self.crontab: + next = None # adhere to the crontab + else: + # no crontab given so we might just query immediately + next = shtime.now() + self.scheduler_add(self.get_fullname(), self.poll_device, prio=5, cycle=self.cycle, cron=self.crontab, next=next) self.logger.debug('run method finished') def stop(self): @@ -209,10 +214,13 @@ def stop(self): """ self.logger.debug('stop method called') self.alive = False - try: - self.scheduler_remove(self.get_fullname()) - except Exception: - pass + if self.use_asyncio: + self.stop_asyncio() + else: + try: + self.scheduler_remove(self.get_fullname()) + except Exception: + pass def _load_parameters(self): @@ -280,7 +288,16 @@ def _load_parameters(self): if self.crontab == '': self.crontab = None - if not (self.cycle or self.crontab): + self._config['poll'] = True + poll = self.get_parameter_value('poll') + if not poll: + if self.protocol == 'SML': + self.use_asyncio = True + self._config['poll'] = False + else: + self.logger.warning(f'async listening requested but protocol is {self.protocol} instead of SML, reverting to polling') + + if not self.use_asyncio and not (self.cycle or self.crontab): self.logger.warning(f'{self.get_fullname()}: no update cycle or crontab set. The smartmeter will not be queried automatically') def _get_module(self, protocol=None): @@ -391,6 +408,41 @@ def _update_values(self, result: dict): else: self.logger.debug(f'for item {item} and obis code {obis}:{prop} no content was received') + async def plugin_coro(self): + """ + Coroutine for the session that starts the serial connection and listens + """ + self.logger.info("plugin_coro started") + try: + self.reader = sml.SmlAsyncReader(self.logger, self, self._config) + except ImportError as e: + # serial_asyncio not loaded/present + self.logger.error(e) + return + + # start listener and queue listener in parallel + await asyncio.gather(self.reader.stop_on_queue(), self._run_listener()) + + # reader quit, exit loop + self.alive = False + self.logger.info("plugin_coro finished") + + async def _run_listener(self): + """ call async listener and restart if requested """ + while self.alive: + # reader created, run reader + try: + await self.reader.listen() + except Exception as e: + self.logger.warning(f'while running listener, the following error occured: {e}') + + if not self._autoreconnect: + self.logger.debug('listener quit, autoreconnect not set, exiting') + break + + self.logger.debug('listener quit, autoreconnecting after 2 seconds...') + await asyncio.sleep(2) + @property def item_list(self): return self.get_item_list() diff --git a/smartmeter/plugin.yaml b/smartmeter/plugin.yaml index 4a26b43e2..088e62743 100644 --- a/smartmeter/plugin.yaml +++ b/smartmeter/plugin.yaml @@ -45,6 +45,12 @@ parameters: description: de: 'Port für die Kommunikation (nur SML)' en: 'Port for communication (SML only)' + autoreconnect: + type: bool + default: true + description: + de: 'Bei Beenden der Verbindung automatisch erneut verbinden (nur bei asyncio Betrieb)' + en: 'automatically reconnect on disconnect (only with asyncio operation)' timeout: type: int default: 2 @@ -70,6 +76,12 @@ parameters: description: de: 'Abfragen des Smartmeters mit Festlegung via Crontab' en: 'Queries of smartmeter by means of a crontab' + poll: + type: bool + default: true + description: + de: 'Gerät regelmäßig (cycle/crontab) abfragen statt asynchronem Empfang (nur SML)' + en: 'periodically query device (cycle/crontab) instead of asynchronous receive (SML only)' # DLMS parameters baudrate_min: diff --git a/smartmeter/sml.py b/smartmeter/sml.py index 3e1eafb79..75f50db5b 100755 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -33,9 +33,15 @@ __revision__ = "0.1" __docformat__ = 'reStructuredText' +import asyncio import errno import logging import serial +try: + import serial_asyncio + ASYNC_IMPORTED = True +except ImportError: + ASYNC_IMPORTED = False import socket import time import traceback @@ -43,8 +49,9 @@ from smllib.reader import SmlStreamReader from smllib import const as smlConst from threading import Lock -from typing import Union +from typing import (Union, Coroutine) +from lib.model.smartplugin import SmartPlugin """ This module implements the query of a smartmeter using the SML protocol. @@ -77,6 +84,9 @@ S_PARITY = serial.PARITY_NONE S_STOP = serial.STOPBITS_ONE +# serial lock for sync and async readers alike +LOCK = Lock() + if __name__ == '__main__': logger = logging.getLogger(__name__) @@ -143,6 +153,131 @@ def format_time(timedelta): elif timedelta > 0.000000001: return f"{timedelta * 1000000000.0:.2f} ns" +# +# asyncio reader +# + + +class SmlAsyncReader(): + + def __init__(self, logger, plugin: SmartPlugin, config: dict): + self.buf = bytes() + self.logger = logger + + if not ASYNC_IMPORTED: + raise ImportError('pyserial_asyncio not installed, running asyncio not possible.') + self.config = config + self.transport = None + self.stream = SmlStreamReader() + self.fp = SmlFrameParser(config) + self.frame_lock = Lock() + + # set from plugin + self.plugin = plugin + self.data_callback = plugin._update_values + + if not ('serial_port' in config or ('host' in config and 'port' in config)): + raise ValueError(f'configuration {config} is missing source config (serialport or host and port)') + + self.serial_port = config.get('serial_port') + self.host = config.get('host') + self.port = config.get('port') + self.timeout = config.get('timeout', 2) + self.baudrate = config.get('baudrate', 9600) + self.target = '(not set)' + self.buffersize = config.get('sml', {'buffersize': 1024}).get('buffersize', 1024) + self.listening = False + self.reader = None + + async def listen(self): + result = LOCK.acquire(blocking=False) + if not result: + self.logger.error('couldn\'t acquire lock, polling/manual access active?') + return + + self.logger.debug('acquired lock') + try: # LOCK + if self.serial_port: + self.reader, _ = await serial_asyncio.open_serial_connection( + url=self.serial_port, + baudrate=self.baudrate, + bytesize=S_BITS, + parity=S_PARITY, + stopbits=S_STOP, + ) + self.target = f'async_serial://{self.serial_port}' + else: + self.reader, _ = await asyncio.open_connection(self.host, self.port) + self.target = f'async_tcp://{self.host}:{self.port}' + + self.logger.debug(f'target is {self.target}') + if self.reader is None and not TESTING: + self.logger.error('error on setting up async listener, reader is None') + return + + self.async_connected = True + self.listening = True + self.logger.debug('starting to listen') + while self.listening and self.plugin.alive: + if TESTING: + chunk = RESULT + else: + try: + chunk = await self.reader.read(self.buffersize) + except serial.serialutil.SerialException: + # possibly port closed from remote site? happens with socat... + chunk = b'' + if chunk == b'': + self.logger.debug('read reached EOF, quitting') + break + self.logger.debug(f'read {chunk} ({len(chunk)} bytes), buf is {self.buf}') + self.buf += chunk + + if len(self.buf) < 100: + continue + try: + self.stream.add(self.buf) + except Exception as e: + self.logger.error(f'Writing data to SmlStreamReader failed with exception {e}') + else: + self.buf = bytes() + # get frames as long as frames are detected + while True: + try: + frame = self.stream.get_frame() + if frame is None: + break + + self.fp(frame) + except Exception as e: + detail = traceback.format_exc() + self.logger.warning(f'Preparing and parsing data failed with exception {e}: and detail: {detail}') + + # get data from frameparser and call plugin + if self.data_callback: + self.data_callback(self.fp()) + + # just in case of many errors, reset buffer + # with SmlStreamParser, this should not happen anymore, but who knows... + if len(self.buf) > 100000: + self.logger.error("Buffer got to large, doing buffer reset") + self.buf = bytes() + finally: + # cleanup + try: + self.reader.feed_eof() + except Exception: + pass + self.async_connected = False + LOCK.release() + + async def stop_on_queue(self): + """ wait for STOP in queue and signal reader to terminate """ + self.logger.debug('task waiting for STOP from queue...') + await self.plugin. wait_for_asyncio_termination() + self.logger.debug('task received STOP, halting listener') + self.listening = False + # # single-shot reader @@ -159,6 +294,8 @@ def __init__(self, logger, config: dict): if not ('serial_port' in config or ('host' in config and 'port' in config)): raise ValueError(f'configuration {config} is missing source config (serialport or host and port)') + if not config.get('poll') and not ASYNC_IMPORTED: + raise ValueError('async configured but pyserial_asyncio not imported. Aborting.') self.serial_port = config.get('serial_port') self.host = config.get('host') self.port = config.get('port') @@ -166,7 +303,6 @@ def __init__(self, logger, config: dict): self.baudrate = config.get('baudrate', 9600) self.target = '(not set)' self.buffersize = config.get('sml', {'buffersize': 1024}).get('buffersize', 1024) - self.lock = Lock() logger.debug(f"config='{config}'") @@ -175,7 +311,7 @@ def __call__(self) -> bytes: # # open the serial communication # - locked = self.lock.acquire(blocking=False) + locked = LOCK.acquire(blocking=False) if not locked: logger.error('could not get lock for serial/network access. Is another scheduled/manual action still active?') return b'' @@ -215,7 +351,7 @@ def __call__(self) -> bytes: except Exception: pass self.sock = None - self.lock.release() + LOCK.release() return response def _read(self) -> bytes: From 9789b19df51f04e912bde326548b59908e1af617 Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:19:08 +0100 Subject: [PATCH 18/34] smartmeter: update parameters, item updates only every x seconds --- smartmeter/__init__.py | 23 +++++++++++++++++++++++ smartmeter/plugin.yaml | 38 +++++++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index 40ac78b3b..57ba1b6c7 100755 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -30,6 +30,7 @@ import asyncio import threading +import time import sys # find out if we can import serial - if not, the plugin might not start anyway @@ -110,6 +111,10 @@ def __init__(self, sh): self.async_connected = False self.use_asyncio = False + # update items only every x seconds + self.timefilter = -1 + self._last_item_update = -1 + # load parameters from config self._load_parameters() @@ -297,6 +302,14 @@ def _load_parameters(self): else: self.logger.warning(f'async listening requested but protocol is {self.protocol} instead of SML, reverting to polling') + if self.use_asyncio: + self.timefilter = self.get_parameter_value('time_filter') + if self.timefilter == -1: + self.timefilter = self.cycle + if self.timefilter < 0: + self.timefilter = 0 + self._config['timefilter'] = self.timefilter + if not self.use_asyncio and not (self.cycle or self.crontab): self.logger.warning(f'{self.get_fullname()}: no update cycle or crontab set. The smartmeter will not be queried automatically') @@ -372,12 +385,18 @@ def _update_values(self, result: dict): # self.logger.debug(f'running _update_values with {result}') self.obis_results.update(result) + # if "update items only every x seconds" is set: + if self.timefilter > 0 and self._last_item_update + self.timefilter > time.time(): + self.logger.debug(f'timefilter active, {int(self._last_item_update + self.timefilter - time.time())} seconds remaining') + return + if 'readout' in result: for item in self.get_items_for_mapping('readout'): item(result['readout'], self.get_fullname()) self.logger.debug(f'set item {item} to readout {result["readout"]}') del result['readout'] + update = -1 # check all obis codes for obis, vlist in result.items(): if not self._is_obis_code_wanted(obis): @@ -402,11 +421,15 @@ def _update_values(self, result: dict): itemValue = self._convert_value(val, converter) # self.logger.debug(f'conversion yielded {itemValue} from {val} for converter "{converter}"') item(itemValue, self.get_fullname()) + if update < 0: + update = time.time() self.logger.debug(f'set item {item} for obis code {obis}:{prop} to value {itemValue}') except ValueError as e: self.logger.error(f'error while converting value {val} for item {item}, obis code {obis}: {e}') else: self.logger.debug(f'for item {item} and obis code {obis}:{prop} no content was received') + if update > 0: + self._last_item_update = update async def plugin_coro(self): """ diff --git a/smartmeter/plugin.yaml b/smartmeter/plugin.yaml index 088e62743..8e9c46b88 100644 --- a/smartmeter/plugin.yaml +++ b/smartmeter/plugin.yaml @@ -10,7 +10,7 @@ plugin: de: 'Unterstützung für Smartmeter, die DLMS (Device Language Message Specification, IEC 62056-21) oder SML (Smart Message Language) nutzen und OBIS Codes liefern' en: 'Support for smartmeter using DLMS (Device Language Message Specification, IEC 62056-21) or SML (Smart Message Language) and delivering OBIS codes' maintainer: Morg - tester: bmxp, onkelandy + tester: bmxp, onkelandy, sisamiwe state: develop keywords: smartmeter ehz dlms sml obis smartmeter multi_instance: true # plugin supports multi instance @@ -49,8 +49,8 @@ parameters: type: bool default: true description: - de: 'Bei Beenden der Verbindung automatisch erneut verbinden (nur bei asyncio Betrieb)' - en: 'automatically reconnect on disconnect (only with asyncio operation)' + de: 'Bei Beenden der Verbindung automatisch erneut verbinden (nur bei ständigem Empfang)' + en: 'automatically reconnect on disconnect (only with continuous listening)' timeout: type: int default: 2 @@ -80,9 +80,23 @@ parameters: type: bool default: true description: - de: 'Gerät regelmäßig (cycle/crontab) abfragen statt asynchronem Empfang (nur SML)' - en: 'periodically query device (cycle/crontab) instead of asynchronous receive (SML only)' - + de: 'Gerät regelmäßig abfragen (cycle/crontab) statt ständigem Empfang (nur SML)' + en: 'periodically query device (cycle/crontab) instead of continuous listen (SML only)' + time_filter: + type: int + default: 0 + description: + de: 'Im "ständig empfangen"-Modus Item-Updates nur alle x Sekunden senden.' + en: 'In continuous listen mode only update items every x seconds.' + description_long: + de: > + Im "ständig empfangen"-Modus Item-Updates nur alle x Sekunden senden. + x = 0: alle Updates senden. + x = -1: den Wert von "cycle" verwenden. + en: > + In continuous listen mode only update items every x seconds. + x = 0: send all updates + x = -1: use "cycle" parameter for x # DLMS parameters baudrate_min: type: int @@ -131,10 +145,12 @@ parameters: en: 'Size of read buffer. At least twice the size of maximum message length (SML only)' device_type: type: str - default: 'raw' + default: '' + valid_list: + - '' # needs to be edited if new corrections are added description: - de: 'Name des Gerätes (nur SML)' - en: 'Name of Smartmeter (SML only)' + de: 'Typ des Gerätes, ggf. notwendig für spezielle Behandlung (nur SML)' + en: 'type of smartmeter, possibly necessary for quirks (SML only)' date_offset: type: int default: 0 @@ -249,8 +265,8 @@ item_attributes: obis_readout: type: str description: - de: 'Der komplette Auslesepuffer wird für eigene Untersuchungen gespeichert' - en: 'the complete readout will be saved for own examinations' + de: 'Der komplette Auslesepuffer wird für eigene Untersuchungen gespeichert (nur DLMS)' + en: 'the complete readout will be saved for own examinations (DLMS only)' logic_parameters: NONE From 918cd934166159f08aa1f8c84ae095dbd8d4c62e Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Sat, 14 Dec 2024 21:18:55 +0100 Subject: [PATCH 19/34] smartmeter: fix discovery, improve locking --- smartmeter/__init__.py | 36 +++++++-------- smartmeter/dlms.py | 36 +++++++++++---- smartmeter/sml.py | 101 +++++++++++++++++++++++------------------ 3 files changed, 101 insertions(+), 72 deletions(-) diff --git a/smartmeter/__init__.py b/smartmeter/__init__.py index 57ba1b6c7..8fa3706cf 100755 --- a/smartmeter/__init__.py +++ b/smartmeter/__init__.py @@ -108,7 +108,6 @@ def __init__(self, sh): # protocol auto-detected? self.proto_detected = False - self.async_connected = False self.use_asyncio = False # update items only every x seconds @@ -138,10 +137,13 @@ def discover(self, protocol=None) -> bool: for proto in disc_protos: if self._get_module(proto).discover(self._config): + self.logger.info(f'discovery of {protocol} was successful') self.protocol = proto if len(disc_protos) > 1: self.proto_detected = True return True + else: + self.logger.info(f'discovery of {protocol} was unsuccessful') return False @@ -160,23 +162,16 @@ def query(self, assign_values: bool = True, protocol=None) -> dict: ref = self._get_module(protocol) result = {} - if self._lock.acquire(blocking=False): - self.logger.debug('lock acquired') - try: - result = ref.query(self._config) - if not result: - self.logger.warning('no results from smartmeter query received') - else: - self.logger.debug(f'got result: {result}') - if assign_values: - self._update_values(result) - except Exception as e: - self.logger.error(f'error: {e}', exc_info=True) - finally: - self._lock.release() - self.logger.debug('lock released') - else: - self.logger.warning('device query is alrady running. Check connection and/or use longer query interval time.') + try: + result = ref.query(self._config) + if not result: + self.logger.warning('no results from smartmeter query received') + else: + self.logger.debug(f'got result: {result}') + if assign_values: + self._update_values(result) + except Exception as e: + self.logger.error(f'error: {e}', exc_info=True) return result @@ -234,6 +229,9 @@ def _load_parameters(self): # self._config = {} + # not really a config value, but easier than having another parameter everywhere + self._config['lock'] = self._lock + # first try connections; abort loading plugin if no connection is configured self._config['serial_port'] = self.get_parameter_value('serialport') if self._config['serial_port'] and not REQUIRED_PACKAGE_IMPORTED: @@ -304,7 +302,7 @@ def _load_parameters(self): if self.use_asyncio: self.timefilter = self.get_parameter_value('time_filter') - if self.timefilter == -1: + if self.timefilter == -1 and self.cycle is not None: self.timefilter = self.cycle if self.timefilter < 0: self.timefilter = 0 diff --git a/smartmeter/dlms.py b/smartmeter/dlms.py index 6497cce08..a61ad4275 100755 --- a/smartmeter/dlms.py +++ b/smartmeter/dlms.py @@ -212,7 +212,7 @@ def format_time(timedelta: float) -> str: return f"{timedelta * 10 ** 9:.2f} ns" -def read_data_block_from_serial(the_serial: serial.Serial, end_byte: bytes = b'\n', start_byte: bytes = b'', max_read_time: int = -1) -> bytes: +def read_data_block_from_serial(the_serial: serial.Serial, end_byte: bytes = b'\n', start_byte: bytes = b'', max_read_time: int = -1, discover: bool = False) -> bytes: """ This function reads some bytes from serial interface it returns an array of bytes if a timeout occurs or a given end byte is encountered @@ -229,13 +229,20 @@ def read_data_block_from_serial(the_serial: serial.Serial, end_byte: bytes = b'\ if TESTING: return RESULT.encode() + # in discover mode, stop trying after 20 secs + # reading SML yields bytes, but doesn't trigger returning data + if discover: + max_read_time = 20 + logger.debug(f"start to read data from serial device, start is {start_byte}, end is '{end_byte}, time is {max_read_time}") response = bytes() starttime = time.time() start_found = False + end_bytes = 0 ch = bytes() try: - while True: + # try to stop looking if 10 end bytes were found but no start bytes + while not discover or end_bytes < 10: ch = the_serial.read() # logger.debug(f"Read {ch}") runtime = time.time() @@ -244,11 +251,13 @@ def read_data_block_from_serial(the_serial: serial.Serial, end_byte: bytes = b'\ if start_byte != b'': if ch == start_byte: logger.debug('start byte found') + end_bytes = 0 response = bytes() start_found = True response += ch if ch == end_byte: - logger.debug('end byte found') + end_bytes += 1 + logger.debug(f'end byte found ({end_bytes})') if start_byte is not None and not start_found: response = bytes() continue @@ -256,6 +265,7 @@ def read_data_block_from_serial(the_serial: serial.Serial, end_byte: bytes = b'\ break if (response[-1] == end_byte): logger.debug('end byte at end of response found') + end_bytes = 0 break if max_read_time is not None: if runtime - starttime > max_read_time and max_read_time > 0: @@ -517,7 +527,7 @@ def parse(data: str, normalize: bool = True) -> dict: return result -def query(config) -> Union[dict, None]: +def query(config, discover: bool = False) -> Union[dict, None]: """ This function will 1. open a serial communication line to the smartmeter @@ -551,7 +561,7 @@ def query(config) -> Union[dict, None]: # for the performance of the serial read we need to save the current time starttime = time.time() runtime = starttime - lock = Lock() + lock = config['lock'] sock = None if not ('serial_port' in config or ('host' in config and 'port' in config)): @@ -627,7 +637,7 @@ def query(config) -> Union[dict, None]: logger.debug(f"time to send first request to smartmeter: {format_time(time.time() - runtime)}") # now get first response - response = read_data_block_from_serial(sock) + response = read_data_block_from_serial(sock, discover=discover) if not response: logger.debug("no response received upon first request") return @@ -641,7 +651,7 @@ def query(config) -> Union[dict, None]: logger.debug("request message was echoed, need to read the identification message") # now read the capabilities and type/brand line from Smartmeter # e.g. b'/LGZ5\\2ZMD3104407.B32\r\n' - response = read_data_block_from_serial(sock) + response = read_data_block_from_serial(sock, discover=discover) else: logger.debug("request message was not equal to response, treating as identification message") @@ -749,11 +759,11 @@ def query(config) -> Union[dict, None]: # now read the huge data block with all the OBIS codes logger.debug("Reading OBIS data from smartmeter") - response = read_data_block_from_serial(sock, b'') + response = read_data_block_from_serial(sock, b'', discover=discover) else: # only listen mode, starts with / and last char is ! # data will be in between those two - response = read_data_block_from_serial(sock, b'!', b'/') + response = read_data_block_from_serial(sock, b'!', b'/', discover=discover) identification_message = str(response, 'utf-8').splitlines()[0] @@ -769,6 +779,11 @@ def query(config) -> Union[dict, None]: # passthrough, this is only for releasing the lock raise finally: + try: + sock.close() + logger.debug(f'{target} closed') + except Exception: + pass lock.release() logger.debug(f"time for reading OBIS data: {format_time(time.time() - runtime)}") @@ -799,7 +814,7 @@ def discover(config: dict) -> bool: # reduced baud rates or changed parameters, but there would need to be # the need for this. # For now, let's see how well this works... - result = query(config) + result = query(config, discover=True) # result should have one key 'readout' with the full answer and a separate # key for every read OBIS code. If no OBIS codes are read/converted, we can @@ -831,6 +846,7 @@ def discover(config: dict) -> bool: # complete default dict config = { + 'lock': Lock(), 'serial_port': '', 'host': '', 'port': 0, diff --git a/smartmeter/sml.py b/smartmeter/sml.py index 75f50db5b..3925e23ec 100755 --- a/smartmeter/sml.py +++ b/smartmeter/sml.py @@ -84,9 +84,6 @@ S_PARITY = serial.PARITY_NONE S_STOP = serial.STOPBITS_ONE -# serial lock for sync and async readers alike -LOCK = Lock() - if __name__ == '__main__': logger = logging.getLogger(__name__) @@ -163,6 +160,7 @@ class SmlAsyncReader(): def __init__(self, logger, plugin: SmartPlugin, config: dict): self.buf = bytes() self.logger = logger + self.lock = config['lock'] if not ASYNC_IMPORTED: raise ImportError('pyserial_asyncio not installed, running asyncio not possible.') @@ -190,7 +188,7 @@ def __init__(self, logger, plugin: SmartPlugin, config: dict): self.reader = None async def listen(self): - result = LOCK.acquire(blocking=False) + result = self.lock.acquire(blocking=False) if not result: self.logger.error('couldn\'t acquire lock, polling/manual access active?') return @@ -215,7 +213,7 @@ async def listen(self): self.logger.error('error on setting up async listener, reader is None') return - self.async_connected = True + self.plugin.connected = True self.listening = True self.logger.debug('starting to listen') while self.listening and self.plugin.alive: @@ -230,12 +228,13 @@ async def listen(self): if chunk == b'': self.logger.debug('read reached EOF, quitting') break - self.logger.debug(f'read {chunk} ({len(chunk)} bytes), buf is {self.buf}') + # self.logger.debug(f'read {chunk} ({len(chunk)} bytes), buf is {self.buf}') self.buf += chunk if len(self.buf) < 100: continue try: + # self.logger.debug(f'adding {len(self.buf)} bytes to stream') self.stream.add(self.buf) except Exception as e: self.logger.error(f'Writing data to SmlStreamReader failed with exception {e}') @@ -246,8 +245,10 @@ async def listen(self): try: frame = self.stream.get_frame() if frame is None: + # self.logger.debug('didn\'t get frame') break + # self.logger.debug('got frame') self.fp(frame) except Exception as e: detail = traceback.format_exc() @@ -268,8 +269,8 @@ async def listen(self): self.reader.feed_eof() except Exception: pass - self.async_connected = False - LOCK.release() + self.plugin.connected = False + self.lock.release() async def stop_on_queue(self): """ wait for STOP in queue and signal reader to terminate """ @@ -286,9 +287,9 @@ async def stop_on_queue(self): class SmlReader(): def __init__(self, logger, config: dict): - print('init') self.config = config self.sock = None + self.lock = config['lock'] self.logger = logger if not ('serial_port' in config or ('host' in config and 'port' in config)): @@ -311,7 +312,7 @@ def __call__(self) -> bytes: # # open the serial communication # - locked = LOCK.acquire(blocking=False) + locked = self.lock.acquire(blocking=False) if not locked: logger.error('could not get lock for serial/network access. Is another scheduled/manual action still active?') return b'' @@ -351,7 +352,7 @@ def __call__(self) -> bytes: except Exception: pass self.sock = None - LOCK.release() + self.lock.release() return response def _read(self) -> bytes: @@ -403,7 +404,6 @@ def read(self) -> bytes: def get_sock(self): """ open serial or network socket """ - if TESTING: self.sock = 1 self.target = '(test input)' @@ -413,38 +413,50 @@ def get_sock(self): # # open the serial communication # - try: # open serial - self.sock = serial.Serial( - self.serial_port, - self.baudrate, - S_BITS, - S_PARITY, - S_STOP, - timeout=self.timeout - ) - if not self.serial_port == self.sock.name: - logger.debug(f"Asked for {self.serial_port} as serial port, but really using now {self.sock.name}") - self.target = f'serial://{self.sock.name}' - - except FileNotFoundError: - logger.error(f"Serial port '{self.serial_port}' does not exist, please check your port") - return None, '' - except serial.SerialException: - if self.sock is None: - logger.error(f"Serial port '{self.serial_port}' could not be opened") - else: - logger.error(f"Serial port '{self.serial_port}' could be opened but somehow not accessed") - return None, '' - except OSError: - logger.error(f"Serial port '{self.serial_port}' does not exist, please check the spelling") - return None, '' - except Exception as e: - logger.error(f"unforeseen error occurred: '{e}'") - return None, '' + count = 0 + while count < 3: + try: # open serial + count += 1 + self.sock = serial.Serial( + self.serial_port, + self.baudrate, + S_BITS, + S_PARITY, + S_STOP, + timeout=self.timeout + ) + if not self.serial_port == self.sock.name: + logger.debug(f"Asked for {self.serial_port} as serial port, but really using now {self.sock.name}") + self.target = f'serial://{self.sock.name}' + + except FileNotFoundError: + logger.error(f"Serial port '{self.serial_port}' does not exist, please check your port") + return None, '' + except serial.SerialException: + if self.sock is None: + if count < 3: + # count += 1 + logger.error(f"Serial port '{self.serial_port}' could not be opened, retrying {count}/3...") + time.sleep(3) + continue + else: + logger.error(f"Serial port '{self.serial_port}' could not be opened") + else: + logger.error(f"Serial port '{self.serial_port}' could be opened but somehow not accessed") + return None, '' + except OSError: + logger.error(f"Serial port '{self.serial_port}' does not exist, please check the spelling") + return None, '' + except Exception as e: + logger.error(f"unforeseen error occurred: '{e}'") + return None, '' if self.sock is None: - # this should not happen... - logger.error("unforeseen error occurred, serial object was not initialized.") + if count == 3: + logger.error("retries unsuccessful, serial port could not be opened, giving up.") + else: + # this should not happen... + logger.error("retries unsuccessful or unforeseen error occurred, serial object was not initialized.") return None, '' if not self.sock.is_open: @@ -536,7 +548,9 @@ def parse_frame(self, frame): content['value'] = bin(content['valueRaw'] >> 8) # Status as binary string, so not decoded into status bits as above # end TODO - self.result[code].append(content) + # don't return multiple code, only the last one -> overwrite earlier data + # self.result[code].append(content) + self.result[code] = [content] logger.debug(f"found {code} with {content}") @@ -653,6 +667,7 @@ def discover(config: dict) -> bool: # complete default dict config = { + 'lock': Lock(), 'serial_port': '', 'host': '', 'port': 0, From 9b90c04d18644addbb079b4157f3d7878f4bdebe Mon Sep 17 00:00:00 2001 From: Morg42 <43153739+Morg42@users.noreply.github.com> Date: Sun, 15 Dec 2024 16:37:54 +0100 Subject: [PATCH 20/34] smartmeter: update webif --- smartmeter/webif/__init__.py | 41 ++++++++--- smartmeter/webif/index.html | 129 +++++++++++++++++++++++++++++------ 2 files changed, 139 insertions(+), 31 deletions(-) diff --git a/smartmeter/webif/__init__.py b/smartmeter/webif/__init__.py index c530db5b1..1a39ac774 100644 --- a/smartmeter/webif/__init__.py +++ b/smartmeter/webif/__init__.py @@ -24,9 +24,12 @@ # ######################################################################### +import datetime +import time +import os import json -import cherrypy +from lib.item import Items from lib.model.smartplugin import SmartPluginWebIf @@ -34,6 +37,10 @@ # Webinterface of the plugin # ------------------------------------------ +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + class WebInterface(SmartPluginWebIf): @@ -49,6 +56,7 @@ def __init__(self, webif_dir, plugin): self.logger = plugin.logger self.webif_dir = webif_dir self.plugin = plugin + self.items = Items.get_instance() self.tplenv = self.init_template_environment() @@ -78,18 +86,11 @@ def get_data_html(self, dataSet=None): :param dataSet: Dataset for which the data should be returned (standard: None) :return: dict with the data needed to update the web page. """ + # if dataSets are used, define them here if dataSet == 'overview': - # get the new data from the plugin variable called _webdata - - data = {} - for obis, value in self.plugin.obis_results.items(): - if isinstance(value, list): - value = value[0] - data[obis] = value - try: - data = json.dumps(data) + data = json.dumps(self.plugin.obis_results) return data except Exception as e: self.logger.error(f"get_data_html overview exception: {e}") @@ -116,6 +117,26 @@ def get_data_html(self, dataSet=None): if dataSet is None: return + @cherrypy.expose + def submit(self, cmd=None): + + self.logger.warning(f"submit: {cmd=}") + result = None + + if cmd == "detect": + result = {'discovery_successful': self.plugin.discover(), 'protocol': self.plugin.protocol} + + elif cmd == 'query': + result = self.plugin.query(assign_values=False) + + self.logger.warning(f"submit: {result=}") + + if result is not None: + # JSON zurücksenden + cherrypy.response.headers['Content-Type'] = 'application/json' + self.logger.debug(f"Result for web interface: {result}") + return json.dumps(result).encode('utf-8') + @cherrypy.expose def read_data(self): self.plugin.query(assign_values=False) diff --git a/smartmeter/webif/index.html b/smartmeter/webif/index.html index b19ab812b..9577a258c 100644 --- a/smartmeter/webif/index.html +++ b/smartmeter/webif/index.html @@ -80,9 +80,17 @@ shngInsertText(item+'_value', objResponse['items'][item]['value'], 'maintable', 5); shngInsertText(item+'_last_update', objResponse['items'][item]['last_update'], 'maintable', 5); shngInsertText(item+'_last_change', objResponse['items'][item]['last_change'], 'maintable', 5); + + for (obis in objResponse['obis_results']) { + var obis_data = objResponse['obis_results'][obis][0]; + console.log("DOM: " + obis+'_value'); + console.log("VAL: " + JSON.stringify(obis_data)); + shngInsertText(obis+'_value', JSON.stringify(obis_data), 'obis_data_table', 5); + } } // Redraw datatable after cell updates // $('#maintable').DataTable().draw(false); + $('#obis_data_table').DataTable().draw(false); } } @@ -92,6 +100,80 @@ --> + + + + + + + + + + + + + + - + + + +