├── .circleci └── config.yml ├── .coveragerc ├── .gitignore ├── HISTORY.md ├── LICENSE.txt ├── README.md ├── __init__.py ├── iec62056_21 ├── __init__.py ├── client.py ├── constants.py ├── exceptions.py ├── lis200.py ├── messages.py ├── transports.py └── utils.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_bcc.py ├── test_client.py └── test_data.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | jobs: 4 | build: 5 | working_directory: ~/work_dir 6 | docker: 7 | - image: circleci/python:3.6.8 # every job must define an image for the docker executor and subsequent jobs may define a different image. 8 | steps: 9 | - checkout # checkout source code to working directory 10 | - run: 11 | command: | 12 | python -m venv venv 13 | . venv/bin/activate 14 | pip install -r requirements.txt 15 | pip install -e . 16 | - run: 17 | command: | 18 | . venv/bin/activate 19 | pytest -v --cov=iec62056_21 20 | coveralls 21 | 22 | 23 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [report] 5 | exclude_lines = 6 | if self.debug: 7 | pragma: no cover 8 | def __repr__ 9 | raise NotImplementedError 10 | raise NotImplemented 11 | if __name__ == .__main__.: 12 | pass 13 | ignore_errors = True 14 | omit = 15 | *migrations*, *tests* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | 5 | The format is based on `Keep a Changelog: https://keepachangelog.com/en/1.0.0/`, 6 | and this project adheres to `Semantic Versioning: https://semver.org/spec/v2.0.0.html` 7 | 8 | 9 | ## Unreleased 10 | 11 | ### Added 12 | 13 | ### Changed 14 | 15 | ### Deprecated 16 | 17 | ### Removed 18 | 19 | ### Fixed 20 | 21 | ### Security 22 | 23 | 24 | # v.0.0.2 (2019-06-12) 25 | 26 | ### Fixed 27 | 28 | Issue `#2` Dependency problems when installing. 29 | 30 | # v0.0.1 (2019-06-04) 31 | 32 | Initial implementation of IEC 62056-21 with focus on supporting LIS 200 derivative 33 | protocol. -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, Palmlund Wahlgren Innovative Technology AB 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # IEC 62056-21 3 | 4 | [![CircleCI](https://circleci.com/gh/pwitab/iec62056-21/tree/master.svg?style=svg)](https://circleci.com/gh/pwitab/iec62056-21/tree/master) 5 | [![Coverage Status](https://coveralls.io/repos/github/pwitab/iec62056-21/badge.svg)](https://coveralls.io/github/pwitab/iec62056-21) 6 | 7 | A Python library for IEC 62056-21, Direct Local Data Exchange of Energy Meters. 8 | Former IEC 61107 or IEC 1107 9 | 10 | ## Installation 11 | 12 | We only support python 3.6+ 13 | 14 | Install via pip: 15 | 16 | ``` 17 | pip install iec62056-21 18 | ``` 19 | 20 | ## About IEC 62056-21 21 | 22 | IEC 62056-21 (earlier IEC 61107 or sometimes just IEC 1107, is an international 23 | standard for a computer protocol to read utility meters. It is designed to operate 24 | over any media, including the Internet. A meter sends ASCII (in modes A..D) or 25 | HDLC (mode E) data to a nearby hand-held unit (HHU) using a serial port. The physical 26 | media are usually either modulated light, sent with an LED and received with a 27 | photodiode, or a pair of wires, usually modulated by a 20mA current loop. The protocol 28 | is usually half-duplex. 29 | 30 | 31 | ## Limitations of this library. 32 | 33 | * At the moment we only support Mode C. 34 | * We assume that only protocol mode Normal is used. 35 | 36 | ## Example usage: 37 | 38 | Reading a meter using a optical usb probe via the D0-interface. 39 | 40 | ```python 41 | from iec62056_21.client import Iec6205621Client 42 | 43 | client = Iec6205621Client.with_serial_transport(port='/dev/tty_something') 44 | client.connect() 45 | print(client.standard_readout()) 46 | 47 | ``` 48 | 49 | 50 | Reading a meter over an internet connection. 51 | ```python 52 | from iec62056_21.client import Iec6205621Client 53 | 54 | client = Iec6205621Client.with_tcp_transport(address=('192.168.0.1', 8000), device_address='12345678', password='00000000') 55 | client.connect() 56 | print(client.standard_readout()) 57 | ``` 58 | 59 | 60 | ## Derivative protocols 61 | 62 | Some manufacturer are using a derivative protocol to IEC 62056-21. They comply with 63 | most things but might for example not use the access request features according to 64 | standard or they have a slightly different flow in command execution 65 | 66 | This library can be used with some of them. You just need to be aware of the differences. 67 | We provide special handlers for some unique parts that is included in this library. 68 | They might be split into separate libraries in the future. 69 | 70 | ### LIS-200 71 | 72 | A protocol for Elster devices. Main difference is that they have the concept of 73 | locks instead of password and instead of answering the password request you need to 74 | write the password to a certain register. 75 | 76 | ## Development 77 | 78 | This library is developed by Palmlund Wahlgren Innovative Technology AB in Sweden and 79 | is used in our multi utility AMR solution: [Utilitarian](https://docs.utilitarian.io) 80 | 81 | ## Contributing 82 | 83 | * Check for open issues or open a fresh issue to start a discussion around a feature 84 | idea or a bug. 85 | * Fork the repository on GitHub to start making your changes to the master branch (or 86 | branch off of it). 87 | * Write a test which shows that the bug was fixed or that the feature works as expected. 88 | * Send a pull request and bug the maintainer until it gets merged and published. 89 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwitab/iec62056-21/09d9acfa43dffdbbf602caf1e5d638cca1e571f6/__init__.py -------------------------------------------------------------------------------- /iec62056_21/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwitab/iec62056-21/09d9acfa43dffdbbf602caf1e5d638cca1e571f6/iec62056_21/__init__.py -------------------------------------------------------------------------------- /iec62056_21/client.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | 4 | from iec62056_21 import messages, constants, transports, exceptions 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class Iec6205621Client: 10 | """ 11 | A client class for IEC 62056-21. Only validated with meters using mode C. 12 | """ 13 | 14 | BAUDRATES_MODE_C = { 15 | "0": 300, 16 | "1": 600, 17 | "2": 1200, 18 | "3": 2400, 19 | "4": 4800, 20 | "5": 9600, 21 | "6": 19200, 22 | } 23 | ALLOWED_MODES = [ 24 | "readout", 25 | "programming", 26 | "binary", 27 | "manufacturer6", 28 | "manufacturer7", 29 | "manufacturer8", 30 | "manufacturer9", 31 | ] 32 | MODE_CONTROL_CHARACTER = { 33 | "readout": "0", 34 | "programming": "1", 35 | "binary": "2", 36 | "manufacturer6": "6", 37 | "manufacturer7": "7", 38 | "manufacturer8": "8", 39 | "manufacturer9": "9", 40 | } 41 | SHORT_REACTION_TIME = 0.02 42 | REACTION_TIME = 0.2 43 | 44 | def __init__( 45 | self, 46 | transport, 47 | device_address="", 48 | password="00000000", 49 | battery_powered=False, 50 | error_parser_class=exceptions.DummyErrorParser, 51 | ): 52 | 53 | self.transport = transport 54 | self.device_address = device_address 55 | self.password = password 56 | self.battery_powered = battery_powered 57 | self.identification = None 58 | self._switchover_baudrate_char = None 59 | self.manufacturer_id = None 60 | self.use_short_reaction_time = False 61 | self.error_parser = error_parser_class() 62 | self._current_baudrate: int = 300 63 | 64 | if self.transport.TRANSPORT_REQUIRES_ADDRESS and not self.device_address: 65 | raise exceptions.Iec6205621ClientError( 66 | f"The transported used ({self.transport}) requires a device address " 67 | f"and none was supplied." 68 | ) 69 | 70 | @property 71 | def switchover_baudrate(self): 72 | """ 73 | Shortcut to get the baud rate for the switchover. 74 | """ 75 | return self.BAUDRATES_MODE_C.get(self._switchover_baudrate_char) 76 | 77 | def read_single_value(self, address, additional_data="1"): 78 | """ 79 | Reads a value from an address in the device. 80 | 81 | :param address: 82 | :param additional_data: 83 | :return: 84 | """ 85 | # TODO Can't find documentation on why the additional_data of 1 is needed. 86 | # LIS-200 Specific? 87 | 88 | # TODO: When not using the additional data on an EMH meter we get an ack back. 89 | # a bit later we get the break message. Is the device waiting? 90 | 91 | request = messages.CommandMessage.for_single_read(address, additional_data) 92 | logger.info(f"Sending read request: {request}") 93 | self.transport.send(request.to_bytes()) 94 | 95 | response = self.read_response() 96 | 97 | if len(response.data) > 1: 98 | raise exceptions.TooManyValuesReturned( 99 | f"Read of one value returned {len(response.data)}" 100 | ) 101 | if len(response.data) == 0: 102 | raise exceptions.NoDataReturned(f"Read returned no data") 103 | 104 | logger.info(f"Received response: {response}") 105 | # Just return the data, not in a list since it is just one. 106 | return response.data[0] 107 | 108 | def write_single_value(self, address, data): 109 | """ 110 | Writes a value to an address in the device. 111 | 112 | :param address: 113 | :param data: 114 | :return: 115 | """ 116 | 117 | request = messages.CommandMessage.for_single_write(address, data) 118 | logger.info(f"Sending write request: {request}") 119 | self.transport.send(request.to_bytes()) 120 | 121 | ack = self._recv_ack() 122 | if ack == constants.ACK: 123 | logger.info(f"Write request accepted") 124 | return 125 | elif ack == constants.NACK: 126 | # TODO: implement retry and raise proper error. 127 | raise ValueError(f"Received NACK upon sending {request}") 128 | else: 129 | raise ValueError( 130 | f"Received invalid response {ack} to write request {request}" 131 | ) 132 | 133 | def connect(self): 134 | """ 135 | Connect to the device 136 | """ 137 | self.transport.connect() 138 | 139 | def disconnect(self): 140 | """ 141 | Close connection to device 142 | """ 143 | self.transport.disconnect() 144 | 145 | def startup(self): 146 | """ 147 | Initial communication to start the session with the device. Sends a 148 | RequestMessage and receives identification message. 149 | """ 150 | 151 | if self.battery_powered: 152 | self.send_battery_power_startup_sequence() 153 | logger.info("Staring init sequence") 154 | self.send_init_request() 155 | 156 | ident_msg = self.read_identification() 157 | 158 | # Setting the baudrate to the one propsed by the device. 159 | self._switchover_baudrate_char = ident_msg.switchover_baudrate_char 160 | self.identification = ident_msg.identification 161 | self.manufacturer_id = ident_msg.manufacturer 162 | 163 | # If a meter transmits the third letter (last) in lower case, the minimum 164 | # reaction time for the device is 20 ms instead of 200 ms. 165 | if self.manufacturer_id[-1].islower(): 166 | self.use_short_reaction_time = True 167 | 168 | def access_programming_mode(self): 169 | """ 170 | Goes through the steps to set the meter in programming mode. 171 | Returns the password challenge request to be acted on. 172 | """ 173 | 174 | self.startup() 175 | 176 | self.ack_with_option_select("programming") 177 | 178 | # receive password request 179 | pw_req = self.read_response() 180 | 181 | return pw_req 182 | 183 | def standard_readout(self): 184 | """ 185 | Goes through the steps to read the standard readout response from the device. 186 | """ 187 | self.startup() 188 | self.ack_with_option_select("readout") 189 | logger.info(f"Reading standard readout from device.") 190 | response = self.read_response() 191 | return response 192 | 193 | def send_password(self, password=None): 194 | """ 195 | On receiving the password challenge request one must handle the password 196 | challenge according to device specification and then send the password. 197 | :param password: 198 | """ 199 | _pw = password or self.password 200 | data_set = messages.DataSet(value=_pw) 201 | cmd = messages.CommandMessage(command="P", command_type="1", data_set=data_set) 202 | logger.info("Sending password to meter") 203 | self.transport.send(cmd.to_bytes()) 204 | 205 | def send_break(self): 206 | """ 207 | Sending the break message to indicate that one wants to stop the 208 | communication. 209 | """ 210 | logger.info("Sending BREAK message to end communication") 211 | break_msg = messages.CommandMessage( 212 | command="B", command_type="0", data_set=None 213 | ) 214 | self.transport.send(break_msg.to_bytes()) 215 | 216 | def ack_with_option_select(self, mode): 217 | """ 218 | After receiving the identification one needs to respond with an ACK including 219 | the different options for the session. The main usage is to control the 220 | mode. readout, programming, or manufacturer specific. The baudrate change used 221 | will be the one proposed by the device in the identification message. 222 | 223 | :param mode: 224 | """ 225 | # TODO: allow the client to suggest a new baudrate to the devices instead of 226 | # the devices proposed one. 227 | 228 | mode_char = self.MODE_CONTROL_CHARACTER[mode] 229 | 230 | ack_message = messages.AckOptionSelectMessage( 231 | mode_char=mode_char, baud_char=self._switchover_baudrate_char 232 | ) 233 | logger.info(f"Sending AckOptionsSelect message: {ack_message}") 234 | self.transport.send(ack_message.to_bytes()) 235 | self.rest() 236 | self.transport.switch_baudrate( 237 | baud=self.BAUDRATES_MODE_C[self._switchover_baudrate_char] 238 | ) 239 | 240 | def send_init_request(self): 241 | """ 242 | The init request tells the device they you want to start a session with it. 243 | When using the optical interface on the device there is no need to send the 244 | device address in the init request since there can be only one meter. 245 | Over TCP or bus-like transports like RS-485 you will need to specify the meter 246 | you want to talk to by adding the address in the request. 247 | 248 | """ 249 | request = messages.RequestMessage(device_address=self.device_address) 250 | logger.info(f"Sending request message: {request}") 251 | self.transport.send(request.to_bytes()) 252 | self.rest() 253 | 254 | def read_identification(self): 255 | """ 256 | Properly receive the identification message and parse it. 257 | """ 258 | 259 | data = self.transport.simple_read(start_char="/", end_char="\x0a") 260 | 261 | identification = messages.IdentificationMessage.from_bytes(data) 262 | logger.info(f"Received identification message: {identification}") 263 | return identification 264 | 265 | def send_battery_power_startup_sequence(self, fast=False): 266 | """ 267 | Battery powered devices require a startup sequence of null bytes to 268 | activate 269 | There is a normal and a fast start up sequence defined in the protocol. 270 | 271 | Normal: 272 | Null chars should be sent to the device for 2.1-2.3 seconds with a maximum 273 | of 0,5 seconds between them. 274 | After the last charachter the client shall wait 1.5-1,7 seconds until it 275 | sends the request message 276 | 277 | :param fast: 278 | """ 279 | if fast: 280 | raise NotImplemented("Fast startup sequence is not yet implemented") 281 | 282 | timeout = 2.2 283 | duration = 0 284 | start_time = time.time() 285 | logger.info("Sending battery startup sequence") 286 | while duration < timeout: 287 | out = b"\x00" 288 | self.transport.send(out) 289 | self.rest(0.2) 290 | duration = time.time() - start_time 291 | logger.info("Startup Sequence finished") 292 | 293 | self.rest(1.5) 294 | 295 | def _recv_ack(self): 296 | """ 297 | Simple way of receiving an ack or nack. 298 | """ 299 | ack = self.transport.recv(1).decode(constants.ENCODING) 300 | return ack 301 | 302 | def read_response(self, timeout=None): 303 | """ 304 | Reads the response from a device and parses it to the correct message type. 305 | 306 | :param timeout: 307 | """ 308 | data = self.transport.read() 309 | if data.startswith(b"\x01"): 310 | # We probably received a password challenge 311 | return messages.CommandMessage.from_bytes(data) 312 | else: 313 | response = messages.AnswerDataMessage.from_bytes(data) 314 | self.error_parser.check_for_errors(response) 315 | return response 316 | 317 | @property 318 | def reaction_time(self): 319 | """ 320 | The device can define two different reaction times. Depending if the third 321 | letter in the manufacturer ID in the identification request is in lower case the 322 | shorter reaction time is used. 323 | """ 324 | if self.use_short_reaction_time: 325 | return self.SHORT_REACTION_TIME 326 | else: 327 | return self.REACTION_TIME 328 | 329 | def rest(self, duration=None): 330 | """ 331 | The protocol needs some timeouts between reads and writes to enable the device 332 | to properly parse a message and return the result. 333 | """ 334 | 335 | _duration = duration or (self.reaction_time * 1.25) 336 | logger.debug(f"Resting for {_duration} seconds") 337 | time.sleep(_duration) 338 | 339 | @classmethod 340 | def with_serial_transport( 341 | cls, 342 | port, 343 | device_address="", 344 | password="00000000", 345 | battery_powered=False, 346 | error_parser_class=exceptions.DummyErrorParser, 347 | ): 348 | """ 349 | Initiates the client with a serial transport. 350 | 351 | :param port: 352 | :param device_address: 353 | :param password: 354 | :param battery_powered: 355 | :return: 356 | """ 357 | transport = transports.SerialTransport(port=port) 358 | return cls( 359 | transport, device_address, password, battery_powered, error_parser_class 360 | ) 361 | 362 | @classmethod 363 | def with_tcp_transport( 364 | cls, 365 | address, 366 | device_address="", 367 | password="00000000", 368 | battery_powered=False, 369 | error_parser_class=exceptions.DummyErrorParser, 370 | ): 371 | """ 372 | Initiates the client with a TCP Transport. 373 | 374 | :param address: 375 | :param device_address: 376 | :param password: 377 | :param battery_powered: 378 | :return: 379 | """ 380 | transport = transports.TcpTransport(address=address) 381 | return cls( 382 | transport, device_address, password, battery_powered, error_parser_class 383 | ) 384 | -------------------------------------------------------------------------------- /iec62056_21/constants.py: -------------------------------------------------------------------------------- 1 | SOH = "\x01" # Start of header 2 | STX = "\x02" # Frame start char 3 | ETX = "\03" # Frame end char 4 | EOT = "\04" # End of transmission 5 | ACK = "\x06" 6 | NACK = "\x15" 7 | LINE_END = "\x0d\x0a" 8 | START_CHAR = "/" 9 | END_CHAR = "!" 10 | REQUEST_CHAR = "?" 11 | BREAK = f"{SOH}B0{ETX}" # still need to calculate BCC 12 | ENCODING = "latin-1" 13 | -------------------------------------------------------------------------------- /iec62056_21/exceptions.py: -------------------------------------------------------------------------------- 1 | class Iec6205621Exception(Exception): 2 | """General IEC62056-21 Exception""" 3 | 4 | 5 | class Iec6205621ClientError(Iec6205621Exception): 6 | """Client error""" 7 | 8 | 9 | class Iec6205621ParseError(Iec6205621Exception): 10 | """Error in parsing IEC62056-21 data""" 11 | 12 | 13 | class ValidationError(Iec6205621Exception): 14 | """Not valid data error""" 15 | 16 | 17 | class TooManyValuesReturned(Iec6205621Exception): 18 | """If a request for a single value returned more than one value""" 19 | 20 | 21 | class NoDataReturned(Iec6205621Exception): 22 | """No data was returned""" 23 | 24 | 25 | class Iec6206521BaseErrorParser: 26 | """ 27 | Error messages are contained in DataSets and values without unit. Their format is 28 | manufacturer specific so the library can only define a way to handle them not the 29 | exact implementation. 30 | The DummyErrorParser will we used as standard that ignores all errors. 31 | An ErrorParser should take an answer response and parse each data set in it to see 32 | if there is any errors. It should raise appropriate exceptions. 33 | """ 34 | 35 | def __init__(self): 36 | pass 37 | 38 | def check_for_errors(self, answer_response): 39 | raise NotImplementedError("check_for_errors must be implemented in subclass") 40 | 41 | 42 | class DummyErrorParser(Iec6206521BaseErrorParser): 43 | """ 44 | A Dummy parser that fits in as default. Should be overridden if you want to define 45 | an error parser. 46 | """ 47 | 48 | def check_for_errors(self, answer_response): 49 | pass 50 | -------------------------------------------------------------------------------- /iec62056_21/lis200.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | import re 3 | import attr 4 | 5 | from iec62056_21.messages import Iec6205621Data 6 | from iec62056_21 import constants, utils, exceptions 7 | 8 | 9 | def datetime_is_aware(d): 10 | return d.tzinfo is not None and d.tzinfo.utcoffset(d) is not None 11 | 12 | 13 | def format_datetime(dt): 14 | if datetime_is_aware(dt): 15 | raise ValueError("Lis200 does not handle timezone aware datetime objects.") 16 | return dt.strftime("%Y-%m-%d,%H:%M:%S") 17 | 18 | 19 | def parse_datetime(datetime_string, utc_offset=None): 20 | date = datetime.strptime(datetime_string, "%Y-%m-%d,%H:%M:%S") 21 | 22 | if utc_offset: 23 | offset_tz = timezone(timedelta(seconds=utc_offset)) 24 | date = date.replace(tzinfo=offset_tz) 25 | return date 26 | 27 | 28 | class ArchiveReadoutCommand(Iec6205621Data): 29 | """ 30 | A spacial readout commnad is needed to read archives. 31 | 32 | Each data row contains the measurements save at a certain point in time. 33 | Any range of the data in the archive can be read out. How to read the archive is 34 | specified withing the data in the readout command. within the parenthesises. 35 | 36 | For attributes !=0 there is only one data row in the answer. 37 | 38 | :param int archive: Number of the archive to be read. 39 | :param str attribute: Attribute to read. Defaults to `'0'`. 40 | * 0 = Value 41 | * 1 = Rights of Access 42 | * 2 = Description 43 | * 3 = Units (Text) 44 | * 4 = Source 45 | * 5 = Units (Code) 46 | * 6 = Format 47 | * 7 = Data type 48 | * 8 = Settable source of archive (Device dependant) 49 | * 9 = Reserved 50 | * A = Number of data sets in range. 51 | :param int position: The column number of the controlling value in the archive. 52 | Example if timestamp is the third value in the archive you need to set the 53 | position to 3 to specify a time range. Defaults to `1`. Max value is 99. 54 | :param str start: Lower limit (oldest data row) of the archive field to be read out. 55 | Allowed lenght is 17 so it can contain timestamps. Defaults to `''` and if not 56 | present the oldest available row is used as lower limit 57 | :param str end: Upper limit (newest data row) of the archive field to be read out. 58 | Allowed lenght is 17 so it can contain timestamps. Defaults to `''` and if not 59 | present the newest available data row is used as the upper limit. 60 | :param bool partial_blocks: Indicates if readout should be done via partial blocks. 61 | Defaults to `False`. 62 | :param int rows_per_block: If partial_blocks is True then rows per block controls 63 | how many rows is sent in each partial block. Defaults to `100`. 64 | """ 65 | 66 | def __init__( 67 | self, 68 | archive, 69 | start="", 70 | end="", 71 | position=1, 72 | attribute="0", 73 | partial_blocks=False, 74 | rows_per_block=10, 75 | ): 76 | self.archive = archive 77 | self.attribute = attribute 78 | self.position = position 79 | self.start = start 80 | self.end = end 81 | self.partial_blocks = partial_blocks 82 | self.rows_per_block = rows_per_block 83 | 84 | @classmethod 85 | def from_representation(cls, string_data): 86 | # TODO: regex? 87 | pass 88 | 89 | def to_representation(self): 90 | if self.partial_blocks: 91 | command = "R3" 92 | rows_per_block = self.rows_per_block 93 | else: 94 | command = "R1" 95 | rows_per_block = "" 96 | 97 | rep = ( 98 | f"{constants.SOH}{command}{constants.STX}{self.archive}:V.{self.attribute}" 99 | f"({self.position};{self.start};{self.end};{rows_per_block}){constants.ETX}" 100 | ) 101 | return utils.add_bcc(rep) 102 | 103 | def __repr__(self): 104 | return ( 105 | f"{self.__class__.__name__}(" 106 | f"archive={self.archive!r}," 107 | f"start={self.start!r}," 108 | f"end={self.end!r}," 109 | f"position={self.position!r}," 110 | f"attribute={self.attribute!r}," 111 | f"partial_blocks={self.partial_blocks!r}," 112 | f"rows_per_block={self.rows_per_block!r})" 113 | ) 114 | 115 | 116 | @attr.s 117 | class ArchiveDataPoint: 118 | 119 | timestamp = attr.ib() 120 | value = attr.ib() 121 | address = attr.ib() 122 | unit = attr.ib() 123 | 124 | 125 | class ArchiveReadout: 126 | """ 127 | A normal archive readout is just returning the values to conserve data. 128 | To be able to know the address and unit of the value we need to read other 129 | attributes of the archive. 130 | addresses = attribute 4 131 | units = attribute 3 132 | By combining all the results it is possible to get an AnswerMessage with 133 | addresses, values and units for all data sets. 134 | :return: 135 | """ 136 | 137 | def __init__(self, values, addresses, units, datetime_position, utc_offset): 138 | 139 | self.values = values 140 | self.addresses = addresses 141 | self.units = units 142 | self.datetime_position = datetime_position 143 | self.utc_offset = utc_offset 144 | 145 | @property 146 | def data(self): 147 | data_points = list() 148 | _addresses = self.addresses.data_block.data_lines[0].data_sets 149 | _units = self.units.data_block.data_lines[0].data_sets 150 | 151 | for line in self.values.data_block.data_lines: 152 | 153 | # other positions are refered without initial 0. But that wont work when 154 | # referenceing a list. 155 | datetime_index = self.datetime_position - 1 156 | 157 | timestamp = parse_datetime( 158 | datetime_string=line.data_sets[datetime_index].value, 159 | utc_offset=self.utc_offset, 160 | ) 161 | 162 | for i, data_set in enumerate(line.data_sets): 163 | 164 | # Strip of all left leading zeros since we don't need them. 165 | _address = _addresses[i].value.lstrip("0") 166 | if _units[i].value: 167 | _unit = _units[i].value 168 | else: 169 | _unit = None 170 | _value = data_set.value 171 | _timestamp = timestamp 172 | 173 | data_point = ArchiveDataPoint( 174 | timestamp=_timestamp, value=_value, address=_address, unit=_unit 175 | ) 176 | 177 | data_points.append(data_point) 178 | 179 | return data_points 180 | 181 | 182 | class Lis200Exception(Exception): 183 | """General LIS200 Exception""" 184 | 185 | 186 | class Lis200ProtocolError(Lis200Exception): 187 | """General error in Lis200 protocol""" 188 | 189 | 190 | class WrongAddress(Lis200ProtocolError): 191 | """Code: 1, Wrong (unknown) address""" 192 | 193 | 194 | class ObjectNotAvailableError(Lis200ProtocolError): 195 | """Code: 2, Wrong address, object not available""" 196 | 197 | 198 | class EntityForObjectNotAvailable(Lis200ProtocolError): 199 | """Code: 3, Wrong address, entity for object not available""" 200 | 201 | 202 | class UnknownAttributeError(Lis200ProtocolError): 203 | """Code: 4, Wrong address, unknown attribute""" 204 | 205 | 206 | class AttributeForObjectNotAvailableError(Lis200ProtocolError): 207 | """Code: 5, Wrong address, attribute for object not available""" 208 | 209 | 210 | class ValueOutsideOfAllowedRangeError(Lis200ProtocolError): 211 | """Code: 6, Value outside of allowed range""" 212 | 213 | 214 | class WriteOnConstantNotExecutableError(Lis200ProtocolError): 215 | """Code: 9, Write command on constant not executable""" 216 | 217 | 218 | class NoInputAllowedError(Lis200ProtocolError): 219 | """Code: 11, No value range available since no input is allowed""" 220 | 221 | 222 | class WrongInputError(Lis200ProtocolError): 223 | """Code: 13, Wrong input""" 224 | 225 | 226 | class UnknownUnitsError(Lis200ProtocolError): 227 | """Code: 14, Unknown units code """ 228 | 229 | 230 | class WrongAccessCodeError(Lis200ProtocolError): 231 | """Code: 17, Wrong access code""" 232 | 233 | 234 | class NoReadAuthorizationError(Lis200ProtocolError): 235 | """Code: 18, No read authorization""" 236 | 237 | 238 | class NoWriteAuthorization(Lis200ProtocolError): 239 | """Code 19, No write authorization""" 240 | 241 | 242 | class FunctionLockedError(Lis200ProtocolError): 243 | """Code: 20, Function is locked""" 244 | 245 | 246 | class ArchiveNumberNotAvailableError(Lis200ProtocolError): 247 | """Code: 100, Archive number not available""" 248 | 249 | 250 | class ValuePositionNotAvailableError(Lis200ProtocolError): 251 | """Code, 101, Value position not available""" 252 | 253 | 254 | class ArchiveEmptyError(Lis200ProtocolError): 255 | """Code: 103, Archive empty""" 256 | 257 | 258 | class LowerLimitNotFound(Lis200ProtocolError): 259 | """Code: 104, Lower limit (From-value) not found""" 260 | 261 | 262 | class UpperLimitNotFound(Lis200ProtocolError): 263 | """Code: 105, Upper limit (To-value) not found""" 264 | 265 | 266 | class MaxLimitOpenArchivesError(Lis200ProtocolError): 267 | """Code: 108, Maximum limit of simultaneous opened archives exceeded""" 268 | 269 | 270 | class ArchiveEntryOverwrittenWhileReadingError(Lis200ProtocolError): 271 | """Code: 109, Archive entry was overwritten while reading out""" 272 | 273 | 274 | class CrcErrorInRecordError(Lis200ProtocolError): 275 | """Code: 110, CRC error in archive data record""" 276 | 277 | 278 | class SourceNotAllowedError(Lis200ProtocolError): 279 | """Code: 180, Source not allowed""" 280 | 281 | 282 | class TelegramSyntaxError(Lis200ProtocolError): 283 | """Code: 200, Syntax error in telegram""" 284 | 285 | 286 | class TelegramWrongPasswordError(Lis200ProtocolError): 287 | """Code: 201, Wrong password in telegram""" 288 | 289 | 290 | class EepromReadError(Lis200ProtocolError): 291 | """Code: 222, EEPROM read error""" 292 | 293 | 294 | class EepromWriteError(Lis200ProtocolError): 295 | """Code: 223, EEPROM write error""" 296 | 297 | 298 | class EncodeChangeError(Lis200ProtocolError): 299 | """Code: 249, Encoder mode not possible / Counter reading cannot be changed""" 300 | 301 | 302 | class Lis200ErrorParser(exceptions.Iec6206521BaseErrorParser): 303 | """ 304 | Error messages in LIS200 are predefined. They all begin with a # and contain an 305 | error number. 306 | 307 | Code Meaning 308 | ---- ------- 309 | 1 Wrong (unknown) address 310 | 2 Wrong address, object not available 311 | 3 Wrong address, entity for object not available 312 | 4 Wrong address, unknown attribute 313 | 5 Wrong address, attribute for object not available 314 | 6 Value outside of allowed range 315 | 9 Write command on constant not executable 316 | 11 No value range available since no input is allowed 317 | 13 Wrong input 318 | 14 Unknown units code 319 | 17 Wrong access code 320 | 18 No read authorization 321 | 19 No write authorization 322 | 20 Function is locked 323 | 100 Archive number not available 324 | 101 Value position not available 325 | 103 Archive empty 326 | 104 Lower limit (From-value) not found 327 | 105 Upper limit (To-value) not found 328 | 108 Maximum limit of simultaneous opened archives exceeded 329 | 109 Archive entry was overwritten while reading out 330 | 110 CRC error in archive data record 331 | 180 Source not allowed 332 | 200 Syntax error in telegram 333 | 201 Wrong password in telegram 334 | 222 EEPROM read error 335 | 223 EEPROM write error 336 | 249 Encoder mode not possible / Counter reading cannot be changed 337 | """ 338 | 339 | ERROR_MAP = { 340 | 1: WrongAddress, 341 | 2: ObjectNotAvailableError, 342 | 3: EntityForObjectNotAvailable, 343 | 4: UnknownAttributeError, 344 | 5: AttributeForObjectNotAvailableError, 345 | 6: ValueOutsideOfAllowedRangeError, 346 | 9: WriteOnConstantNotExecutableError, 347 | 11: NoInputAllowedError, 348 | 13: WrongInputError, 349 | 14: UnknownUnitsError, 350 | 17: WrongAccessCodeError, 351 | 18: NoReadAuthorizationError, 352 | 19: NoWriteAuthorization, 353 | 20: FunctionLockedError, 354 | 100: ArchiveNumberNotAvailableError, 355 | 101: ValuePositionNotAvailableError, 356 | 103: ArchiveEmptyError, 357 | 104: LowerLimitNotFound, 358 | 105: UpperLimitNotFound, 359 | 108: MaxLimitOpenArchivesError, 360 | 109: ArchiveEntryOverwrittenWhileReadingError, 361 | 110: CrcErrorInRecordError, 362 | 180: SourceNotAllowedError, 363 | 200: TelegramSyntaxError, 364 | 201: TelegramWrongPasswordError, 365 | 222: EepromReadError, 366 | 223: EepromWriteError, 367 | 249: EncodeChangeError, 368 | } 369 | 370 | def __init__(self): 371 | super().__init__() 372 | self.regex_for_error = re.compile(r"^#(\d{4})") 373 | 374 | def check_for_errors(self, answer_response): 375 | errors = list() 376 | for item in answer_response.data: 377 | error = re.search(self.regex_for_error, item.value) 378 | if error: 379 | error_text = error.group(1) 380 | error_nr = int(error_text.lstrip("0")) 381 | errors.append(error_nr) 382 | 383 | # just raise the first error 384 | if errors: 385 | raise self.ERROR_MAP[errors[0]]() 386 | -------------------------------------------------------------------------------- /iec62056_21/messages.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing 3 | 4 | from iec62056_21.exceptions import Iec6205621ParseError, ValidationError 5 | from iec62056_21 import constants, utils 6 | 7 | ENCODING = "latin-1" 8 | 9 | 10 | # Regex to be used for parsing data. Compiled once for reuse later. 11 | regex_data_set = re.compile(r"^(.+)\((.*)\)") 12 | regex_data_set_data = re.compile(r"^(.*)\*(.*)") 13 | regex_data_just_value = re.compile(r"^\((.*)\)") 14 | 15 | 16 | class Iec6205621Data: 17 | """ 18 | Base class for IEC 62056-21 messages. 19 | """ 20 | 21 | def to_representation(self): 22 | raise NotImplementedError("Needs to be implemented in subclass") 23 | 24 | def to_bytes(self): 25 | """ 26 | Ensures the correct encoding to bytes. 27 | """ 28 | return self.to_representation().encode(constants.ENCODING) 29 | 30 | @classmethod 31 | def from_representation(cls, string_data): 32 | raise NotImplementedError("Needs to be implemented in subclass") 33 | 34 | @classmethod 35 | def from_bytes(cls, bytes_data): 36 | """ 37 | Ensures the correct decoding from bytes. 38 | """ 39 | 40 | return cls.from_representation(bytes_data.decode(constants.ENCODING)) 41 | 42 | 43 | class DataSet(Iec6205621Data): 44 | 45 | """ 46 | The data set is the smallest component of a response. 47 | It consists of an address and value with optional unit. in the format of 48 | {address}({value}*{unit}) 49 | """ 50 | 51 | EXCLUDE_CHARS = ["(", ")", "/", "!"] 52 | 53 | def __init__(self, value: str, address: str = None, unit: str = None): 54 | 55 | # TODO: in programming mode, protocol mode C the value can be up to 128 chars 56 | 57 | self.address = address 58 | self.value = value 59 | self.unit = unit 60 | 61 | def to_representation(self) -> str: 62 | if self.unit is not None and self.address is not None: 63 | return f"{self.address}({self.value}*{self.unit})" 64 | elif self.address is not None and self.unit is None: 65 | return f"{self.address}({self.value})" 66 | else: 67 | if self.value is None: 68 | return f"()" 69 | else: 70 | return f"({self.value})" 71 | 72 | @classmethod 73 | def from_representation(cls, data_set_string): 74 | just_value = regex_data_just_value.search(data_set_string) 75 | 76 | if just_value: 77 | return cls(address=None, value=just_value.group(1), unit=None) 78 | 79 | first_match = regex_data_set.search(data_set_string) 80 | if not first_match: 81 | raise Iec6205621ParseError( 82 | f"Unable to find address and data in {data_set_string}" 83 | ) 84 | address = first_match.group(1) 85 | values_data = first_match.group(2) 86 | second_match = regex_data_set_data.search(values_data) 87 | if second_match: 88 | return cls( 89 | address=address, value=second_match.group(1), unit=second_match.group(2) 90 | ) 91 | 92 | else: 93 | return cls(address=address, value=values_data, unit=None) 94 | 95 | def __repr__(self): 96 | return ( 97 | f"{self.__class__.__name__}(" 98 | f"value={self.value!r}, " 99 | f"address={self.address!r}, " 100 | f"unit={self.unit!r}" 101 | f")" 102 | ) 103 | 104 | 105 | class DataLine(Iec6205621Data): 106 | """ 107 | A data line is a list of data sets. 108 | """ 109 | 110 | def __init__(self, data_sets): 111 | self.data_sets: typing.List[DataSet] = data_sets 112 | 113 | def to_representation(self): 114 | sets_representation = [_set.to_representation() for _set in self.data_sets] 115 | return "".join(sets_representation) 116 | 117 | @classmethod 118 | def from_representation(cls, string_data): 119 | """ 120 | Is a list of data sets id(value*unit)id(value*unit) 121 | need to split after each ")" 122 | """ 123 | separator = ")" 124 | data_sets = list() 125 | _string_data = string_data 126 | for x in range(0, string_data.count(separator)): 127 | index = _string_data.find(separator) + 1 128 | data_set_string = _string_data[:index] 129 | _string_data = _string_data[index:] 130 | data_set = DataSet.from_representation(data_set_string=data_set_string) 131 | data_sets.append(data_set) 132 | 133 | return cls(data_sets=data_sets) 134 | 135 | def __repr__(self): 136 | return f"{self.__class__.__name__}(" f"data_sets={self.data_sets!r}" f")" 137 | 138 | 139 | class DataBlock(Iec6205621Data): 140 | """ 141 | A data block is a list of DataLines, each ended with a the line end characters 142 | \n\r 143 | """ 144 | 145 | def __init__(self, data_lines): 146 | self.data_lines = data_lines 147 | 148 | def to_representation(self): 149 | lines_rep = [ 150 | (line.to_representation() + constants.LINE_END) for line in self.data_lines 151 | ] 152 | return "".join(lines_rep) 153 | 154 | @classmethod 155 | def from_representation(cls, string_data: str): 156 | lines = string_data.splitlines() 157 | data_lines = [DataLine.from_representation(line) for line in lines] 158 | return cls(data_lines) 159 | 160 | def __repr__(self): 161 | return f"{self.__class__.__name__}(data_lines={self.data_lines!r})" 162 | 163 | 164 | class ReadoutDataMessage(Iec6205621Data): 165 | def __init__(self, data_block): 166 | self.data_block = data_block 167 | 168 | def to_representation(self): 169 | data = ( 170 | f"{constants.STX}{self.data_block.to_representation()}{constants.END_CHAR}" 171 | f"{constants.LINE_END}{constants.ETX}" 172 | ) 173 | 174 | return utils.add_bcc(data) 175 | 176 | @classmethod 177 | def from_representation(cls, string_data: str): 178 | _in_data = string_data 179 | 180 | if not utils.bcc_valid(string_data): 181 | raise ValueError("BCC not valid") 182 | 183 | _in_data = _in_data[1:-5] # remove stx and !ETX bcc 184 | 185 | data_block = DataBlock.from_representation(_in_data) 186 | 187 | return cls(data_block=data_block) 188 | 189 | def __repr__(self): 190 | return f"{self.__class__.__name__}(data_block={self.data_block!r})" 191 | 192 | 193 | class CommandMessage(Iec6205621Data): 194 | allowed_commands = ["P", "W", "R", "E", "B"] 195 | allowed_command_types = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] 196 | 197 | def __init__( 198 | self, command: str, command_type: str, data_set: typing.Optional[DataSet] 199 | ): 200 | self.command = command 201 | self.command_type = command_type 202 | self.data_set = data_set 203 | 204 | if command not in self.allowed_commands: 205 | raise ValueError(f"{command} is not an allowed command") 206 | if command_type not in self.allowed_command_types: 207 | raise ValueError(f"{command_type} is not an allowed command type") 208 | 209 | def to_representation(self): 210 | header = f"{constants.SOH}{self.command}{self.command_type}" 211 | if self.data_set: 212 | body = f"{constants.STX}{self.data_set.to_representation()}{constants.ETX}" 213 | else: 214 | body = f"{constants.ETX}" 215 | 216 | message = f"{header}{body}" 217 | 218 | return utils.add_bcc(message) 219 | 220 | @classmethod 221 | def from_representation(cls, string_data): 222 | if not utils.bcc_valid(string_data): 223 | raise ValueError("BCC not valid") 224 | _message = string_data[:-1] # remove bcc 225 | header = _message[:3] 226 | body = _message[3:] 227 | 228 | command = header[1] 229 | command_type = header[2] 230 | data_set = DataSet.from_representation(body[1:-1]) 231 | 232 | return cls(command, command_type, data_set) 233 | 234 | @classmethod 235 | def for_single_read(cls, address, additional_data=None): 236 | if additional_data: 237 | _add_data = additional_data 238 | else: 239 | _add_data = "" 240 | data_set = DataSet(value=_add_data, address=address) 241 | return cls(command="R", command_type="1", data_set=data_set) 242 | 243 | @classmethod 244 | def for_single_write(cls, address, value): 245 | data_set = DataSet(value=value, address=address) 246 | return cls(command="W", command_type="1", data_set=data_set) 247 | 248 | def __repr__(self): 249 | return ( 250 | f"{self.__class__.__name__}(" 251 | f"command={self.command!r}, " 252 | f"command_type={self.command_type!r}, " 253 | f"data_set={self.data_set!r}" 254 | f")" 255 | ) 256 | 257 | 258 | class AnswerDataMessage(Iec6205621Data): 259 | def __init__(self, data_block): 260 | self.data_block = data_block 261 | self._data = None 262 | 263 | @property 264 | def data(self): 265 | if not self._data: 266 | self._get_all_data_sets() 267 | 268 | return self._data 269 | 270 | def _get_all_data_sets(self): 271 | data_sets = list() 272 | 273 | for line in self.data_block.data_lines: 274 | for set in line.data_sets: 275 | data_sets.append(set) 276 | 277 | self._data = data_sets 278 | 279 | def to_representation(self): 280 | # TODO: this is not valid in case reading out partial blocks. 281 | rep = f"{constants.STX}{self.data_block.to_representation()}{constants.ETX}" 282 | 283 | return utils.add_bcc(rep) 284 | 285 | @classmethod 286 | def from_representation(cls, string_data): 287 | _in_data = string_data 288 | 289 | if not utils.bcc_valid(string_data): 290 | raise ValueError("BCC not valid") 291 | 292 | _in_data = _in_data[1:-2] # remove stx -- etx bcc 293 | 294 | data_block = DataBlock.from_representation(_in_data) 295 | 296 | return cls(data_block=data_block) 297 | 298 | def __repr__(self): 299 | return f"{self.__class__.__name__}(data_block={self.data_block!r})" 300 | 301 | 302 | class RequestMessage(Iec6205621Data): 303 | def __init__(self, device_address=""): 304 | self.device_address = device_address 305 | 306 | def to_representation(self): 307 | return ( 308 | f"{constants.START_CHAR}{constants.REQUEST_CHAR}{self.device_address}" 309 | f"{constants.END_CHAR}{constants.LINE_END}" 310 | ) 311 | 312 | @classmethod 313 | def from_representation(cls, string_data): 314 | device_address = string_data[2:-3] 315 | return cls(device_address) 316 | 317 | def __repr__(self): 318 | return f"{self.__class__.__name__}(device_address={self.device_address!r})" 319 | 320 | 321 | class AckOptionSelectMessage(Iec6205621Data): 322 | """ 323 | Only support protocol mode 0: Normal 324 | """ 325 | 326 | def __init__(self, baud_char, mode_char): 327 | self.baud_char = baud_char 328 | self.mode_char = mode_char 329 | 330 | def to_representation(self): 331 | return f"{constants.ACK}0{self.baud_char}{self.mode_char}{constants.LINE_END}" 332 | 333 | @classmethod 334 | def from_representation(cls, string_data): 335 | baud_char = string_data[2] 336 | mode_char = string_data[3] 337 | return cls(baud_char, mode_char) 338 | 339 | def __repr__(self): 340 | return ( 341 | f"{self.__class__.__name__}(" 342 | f"baud_char={self.baud_char!r}, " 343 | f"mode_char={self.mode_char!r}" 344 | f")" 345 | ) 346 | 347 | 348 | class IdentificationMessage(Iec6205621Data): 349 | def __init__( 350 | self, identification: str, manufacturer: str, switchover_baudrate_char: str 351 | ): 352 | self.identification: str = identification 353 | self.manufacturer: str = manufacturer 354 | self.switchover_baudrate_char: str = switchover_baudrate_char 355 | 356 | def to_representation(self): 357 | return ( 358 | f"{constants.START_CHAR}{self.manufacturer}{self.switchover_baudrate_char}\\" 359 | f"{self.identification}{constants.LINE_END}" 360 | ) 361 | 362 | @classmethod 363 | def from_representation(cls, string_data): 364 | manufacturer = string_data[1:4] 365 | switchover_baudrate_char = string_data[4] 366 | identification = string_data[6:-2] 367 | 368 | return cls(identification, manufacturer, switchover_baudrate_char) 369 | 370 | def __repr__(self): 371 | return ( 372 | f"{self.__class__.__name__}(" 373 | f"identification={self.identification!r}, " 374 | f"manufacturer={self.manufacturer!r}, " 375 | f"switchover_baudrate_char={self.switchover_baudrate_char!r}" 376 | f")" 377 | ) 378 | -------------------------------------------------------------------------------- /iec62056_21/transports.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | from typing import Tuple, Union, Optional 4 | 5 | import serial 6 | import socket 7 | from iec62056_21 import utils, exceptions, constants 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class TransportError(Exception): 13 | """General transport error""" 14 | 15 | 16 | class BaseTransport: 17 | """ 18 | Base transport class for IEC 62056-21 communication. 19 | """ 20 | 21 | TRANSPORT_REQUIRES_ADDRESS: bool = True 22 | 23 | def __init__(self, timeout: int = 30): 24 | self.timeout = timeout 25 | 26 | def connect(self) -> None: 27 | raise NotImplemented("Must be defined in subclass") 28 | 29 | def disconnect(self) -> None: 30 | raise NotImplemented("Must be defined in subclass") 31 | 32 | def read(self, timeout: Optional[int] = None) -> bytes: 33 | """ 34 | Will read a normal readout. Supports both full and partial block readout. 35 | When using partial blocks it will recreate the messages as it was not sent with 36 | partial blocks 37 | 38 | :param timeout: 39 | :return: 40 | """ 41 | start_chars = [b"\x01", b"\x02"] 42 | end_chars = [b"\x03", b"\x04"] 43 | total_data = b"" 44 | packets = 0 45 | start_char_received = False 46 | start_char = None 47 | end_char = None 48 | timeout = timeout or self.timeout 49 | 50 | while True: 51 | 52 | in_data = b"" 53 | duration: float = 0.0 54 | start_time = time.time() 55 | while True: 56 | b = self.recv(1) 57 | 58 | duration = time.time() - start_time 59 | if duration > self.timeout: 60 | raise TimeoutError(f"Read in {self.__class__.__name__} timed out") 61 | if not start_char_received: 62 | # is start char? 63 | if b in start_chars: 64 | in_data += b 65 | start_char_received = True 66 | start_char = b 67 | continue 68 | else: 69 | continue 70 | else: 71 | # is end char? 72 | if b in end_chars: 73 | in_data += b 74 | end_char = b 75 | break 76 | else: 77 | in_data += b 78 | continue 79 | 80 | packets += 1 81 | 82 | bcc = self.recv(1) 83 | in_data += bcc 84 | logger.debug( 85 | f"Received {in_data!r} over transport: {self.__class__.__name__}" 86 | ) 87 | 88 | if start_char == b"\x01": 89 | # This is a command message, probably Password challange. 90 | total_data += in_data 91 | break 92 | 93 | if end_char == b"\x04": # EOT (partial read) 94 | # we received a partial block 95 | if not utils.bcc_valid(in_data): 96 | # Nack and read again 97 | self.send(constants.NACK.encode(constants.ENCODING)) 98 | continue 99 | else: 100 | # ack and read next 101 | self.send(constants.ACK.encode(constants.ENCODING)) 102 | # remove bcc and eot and add line end. 103 | in_data = in_data[:-2] + constants.LINE_END.encode( 104 | constants.ENCODING 105 | ) 106 | if packets > 1: 107 | # remove the leading STX 108 | in_data = in_data[1:] 109 | 110 | total_data += in_data 111 | continue 112 | 113 | if end_char == b"\x03": 114 | # Either it was the only message or we got the last message. 115 | if not utils.bcc_valid(in_data): 116 | # Nack and read again 117 | self.send(constants.NACK.encode(constants.ENCODING)) 118 | continue 119 | else: 120 | if packets > 1: 121 | in_data = in_data[1:] # removing the leading STX 122 | total_data += in_data 123 | if packets > 1: 124 | # The last bcc is not correct compared to the whole 125 | # message. But we have verified all the bccs along the way so 126 | # we just compute it so the message is usable. 127 | total_data = utils.add_bcc(total_data[:-1]) 128 | 129 | break 130 | 131 | return total_data 132 | 133 | def simple_read( 134 | self, 135 | start_char: Union[str, bytes], 136 | end_char: Union[str, bytes], 137 | timeout: Optional[int] = None, 138 | ) -> bytes: 139 | """ 140 | A more flexible read for use with some messages. 141 | """ 142 | _start_char = utils.ensure_bytes(start_char) 143 | _end_char = utils.ensure_bytes(end_char) 144 | 145 | in_data = b"" 146 | start_char_received = False 147 | timeout = timeout or self.timeout 148 | duration: float = 0.0 149 | start_time = time.time() 150 | while True: 151 | b = self.recv(1) 152 | duration = time.time() - start_time 153 | if duration > self.timeout: 154 | raise TimeoutError(f"Read in {self.__class__.__name__} timed out") 155 | if not start_char_received: 156 | # is start char? 157 | if b == _start_char: 158 | in_data += b 159 | start_char_received = True 160 | continue 161 | else: 162 | continue 163 | else: 164 | # is end char? 165 | if b == _end_char: 166 | in_data += b 167 | break 168 | else: 169 | in_data += b 170 | continue 171 | 172 | logger.debug(f"Received {in_data!r} over transport: {self.__class__.__name__}") 173 | return in_data 174 | 175 | def send(self, data: bytes) -> None: 176 | """ 177 | Will send data over the transport 178 | 179 | :param data: 180 | """ 181 | self._send(data) 182 | logger.debug(f"Sent {data!r} over transport: {self.__class__.__name__}") 183 | 184 | def _send(self, data: bytes) -> None: 185 | """ 186 | Transport dependant sending functionality. 187 | 188 | :param data: 189 | """ 190 | raise NotImplemented("Must be defined in subclass") 191 | 192 | def recv(self, chars: int) -> bytes: 193 | """ 194 | Will receive data over the transport. 195 | 196 | :param chars: 197 | """ 198 | return self._recv(chars) 199 | 200 | def _recv(self, chars: int) -> bytes: 201 | """ 202 | Transport dependant sending functionality. 203 | 204 | :param chars: 205 | """ 206 | raise NotImplemented("Must be defined in subclass") 207 | 208 | def switch_baudrate(self, baud: int) -> None: 209 | """ 210 | The protocol defines a baudrate switchover process. Though it might not be used 211 | in all available transports. 212 | 213 | :param baud: 214 | """ 215 | raise NotImplemented("Must be defined in subclass") 216 | 217 | 218 | class SerialTransport(BaseTransport): 219 | """ 220 | Transport class for communication over serial interface. 221 | Mostly used with Optical probes or USB converters. 222 | 223 | """ 224 | 225 | TRANSPORT_REQUIRES_ADDRESS = False 226 | 227 | def __init__(self, port: str, timeout: int = 10): 228 | 229 | super().__init__(timeout=timeout) 230 | self.port_name: str = port 231 | self.port: Optional[serial.Serial] = None 232 | 233 | def connect(self, baudrate: int = 300) -> None: 234 | """ 235 | Creates a serial port. 236 | """ 237 | self.port = serial.Serial( 238 | self.port_name, 239 | baudrate=baudrate, 240 | parity=serial.PARITY_EVEN, 241 | stopbits=serial.STOPBITS_ONE, 242 | bytesize=serial.SEVENBITS, 243 | writeTimeout=0, 244 | timeout=self.timeout / 2, 245 | rtscts=False, 246 | dsrdtr=False, 247 | xonxoff=False, 248 | ) 249 | 250 | def disconnect(self) -> None: 251 | """ 252 | Closes and removes the serial port. 253 | """ 254 | if self.port is None: 255 | raise TransportError("Serial port is closed.") 256 | 257 | self.port.close() 258 | self.port = None 259 | 260 | def _send(self, data: bytes) -> None: 261 | """ 262 | Sends data over the serial port. 263 | 264 | :param data: 265 | """ 266 | if self.port is None: 267 | raise TransportError("Serial port is closed.") 268 | 269 | self.port.write(data) 270 | self.port.flush() 271 | 272 | def _recv(self, chars: int = 1) -> bytes: 273 | """ 274 | Receives data over the serial port. 275 | 276 | :param chars: 277 | """ 278 | if self.port is None: 279 | raise TransportError("Serial port is closed.") 280 | 281 | return self.port.read(chars) 282 | 283 | def switch_baudrate(self, baud: int) -> None: 284 | """ 285 | Creates a new serial port with the correct baudrate. 286 | 287 | :param baud: 288 | """ 289 | if self.port is None: 290 | raise TransportError("Serial port is closed.") 291 | 292 | logger.info(f"Switching baudrate to: {baud}") 293 | self.port = self.port = serial.Serial( 294 | self.port_name, 295 | baudrate=baud, 296 | parity=serial.PARITY_EVEN, 297 | stopbits=serial.STOPBITS_ONE, 298 | bytesize=serial.SEVENBITS, 299 | writeTimeout=0, 300 | timeout=self.timeout, 301 | rtscts=False, 302 | dsrdtr=False, 303 | xonxoff=False, 304 | ) 305 | 306 | def __repr__(self): 307 | return ( 308 | f"{self.__class__.__name__}(" 309 | f"port={self.port_name!r}, " 310 | f"timeout={self.timeout!r}" 311 | ) 312 | 313 | 314 | class TcpTransport(BaseTransport): 315 | 316 | """ 317 | Transport class for TCP/IP communication. 318 | """ 319 | 320 | def __init__(self, address: Tuple[str, int], timeout: int = 30): 321 | 322 | super().__init__(timeout=timeout) 323 | self.address = address 324 | self.socket: Optional[socket.socket] = self._get_socket() 325 | 326 | def connect(self) -> None: 327 | """ 328 | Connects the socket to the device network interface. 329 | """ 330 | 331 | if not self.socket: 332 | self.socket = self._get_socket() 333 | logger.debug(f"Connecting to {self.address}") 334 | self.socket.connect(self.address) 335 | 336 | def disconnect(self) -> None: 337 | """ 338 | Closes and removes the socket. 339 | """ 340 | if self.socket is None: 341 | raise TransportError("Socket is closed") 342 | 343 | self.socket.close() 344 | self.socket = None 345 | 346 | def _send(self, data: bytes) -> None: 347 | """ 348 | Sends data over the socket. 349 | 350 | :param data: 351 | """ 352 | if self.socket is None: 353 | raise TransportError("Socket is closed") 354 | 355 | self.socket.sendall(data) 356 | 357 | def _recv(self, chars: int = 1) -> bytes: 358 | """ 359 | Receives data from the socket. 360 | 361 | :param chars: 362 | """ 363 | if self.socket is None: 364 | raise TransportError("Socket is closed") 365 | 366 | try: 367 | b = self.socket.recv(chars) 368 | except (OSError, IOError, socket.timeout, socket.error) as e: 369 | raise TransportError from e 370 | return b 371 | 372 | def switch_baudrate(self, baud: int) -> None: 373 | """ 374 | Baudrate has not meaning in TCP/IP so we just dont do anything. 375 | 376 | :param baud: 377 | """ 378 | pass 379 | 380 | def _get_socket(self) -> socket.socket: 381 | """ 382 | Create a correct socket. 383 | """ 384 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 385 | s.settimeout(self.timeout) 386 | return s 387 | 388 | def __repr__(self): 389 | return ( 390 | f"{self.__class__.__name__}(" 391 | f"address={self.address!r}, " 392 | f"timeout={self.timeout!r}" 393 | ) 394 | -------------------------------------------------------------------------------- /iec62056_21/utils.py: -------------------------------------------------------------------------------- 1 | from iec62056_21 import constants 2 | 3 | 4 | def bcc_valid(message): 5 | bcc = message[-1] 6 | to_calc = message[:-1] 7 | calc = add_bcc(to_calc) 8 | if message == calc: 9 | return True 10 | else: 11 | return False 12 | 13 | 14 | def add_bcc(message): 15 | """ 16 | Returns the message with BCC added. 17 | Data to use starts after STX and ends with but includes ETX 18 | If there is a SOH in the message the calculation should be done from there. 19 | """ 20 | 21 | if isinstance(message, str): 22 | _message = message.encode(constants.ENCODING) 23 | return _add_bcc(_message).decode(constants.ENCODING) 24 | return _add_bcc(message) 25 | 26 | 27 | def _add_bcc(message: bytes): 28 | start_bcc_index = 1 29 | soh_index = message.find(constants.SOH.encode(constants.ENCODING)) 30 | if soh_index == -1: 31 | # SOH not found 32 | stx_index = message.find(constants.STX.encode(constants.ENCODING)) 33 | if stx_index == -1: 34 | raise IndexError("No SOH or STX found i message") 35 | start_bcc_index = stx_index + 1 36 | else: 37 | start_bcc_index = soh_index + 1 38 | 39 | data_for_bcc = message[start_bcc_index:] 40 | bcc = calculate_bcc(data_for_bcc) 41 | return message + bcc 42 | 43 | 44 | def calculate_bcc(data): 45 | """ 46 | Calculate BCC. 47 | """ 48 | if isinstance(data, str): 49 | _bcc = _calculate_bcc(data.encode(constants.ENCODING)) 50 | return _bcc.decode(constants.ENCODING) 51 | 52 | return _calculate_bcc(data) 53 | 54 | 55 | def _calculate_bcc(bytes_data: bytes): 56 | bcc = 0 57 | for b in bytes_data: 58 | x = b & 0x7F 59 | bcc ^= x 60 | bcc &= 0x7F 61 | return bcc.to_bytes(length=1, byteorder="big") 62 | 63 | 64 | def ensure_bytes(data): 65 | if isinstance(data, str): 66 | return data.encode(constants.ENCODING) 67 | elif isinstance(data, bytes): 68 | return data 69 | else: 70 | raise ValueError(f"data:{data!r} cant be converted to bytes") 71 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==19.1.0 2 | 3 | #pytest 4 | pytest==4.4.1 5 | pytest-sugar==0.9.2 6 | pytest-mock==1.10.1 7 | pytest-cov==2.6.1 8 | coveralls 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | # This includes the license file in the wheel. 3 | license_file = LICENSE.txt 4 | 5 | [bdist_wheel] 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages, Command 2 | import sys 3 | import os 4 | from shutil import rmtree 5 | 6 | # Package meta-data. 7 | NAME = "iec62056-21" 8 | DESCRIPTION = "A Python library for IEC62056-21, Local Data Readout of Energy Meters. Former IEC1107" 9 | URL = "https://github.com/pwitab/iec62056-21" 10 | EMAIL = "henrik@pwit.se" 11 | AUTHOR = "Henrik Palmlund Wahlgren @ Palmlund Wahlgren Innovative Technology AB" 12 | REQUIRES_PYTHON = "~=3.6" 13 | VERSION = "0.0.2" 14 | 15 | # What packages are required for this module to be executed? 16 | REQUIRED = ["attrs>=19.1.0", "pyserial>=3.4"] 17 | 18 | # What packages are optional? 19 | EXTRAS = {} 20 | 21 | here = os.path.abspath(os.path.dirname(__file__)) 22 | 23 | 24 | class UploadCommand(Command): 25 | """Support setup.py upload.""" 26 | 27 | description = "Build and publish the package." 28 | user_options = [] 29 | 30 | @staticmethod 31 | def status(s): 32 | """Prints things in bold.""" 33 | print("\033[1m{0}\033[0m".format(s)) 34 | 35 | def initialize_options(self): 36 | pass 37 | 38 | def finalize_options(self): 39 | pass 40 | 41 | def run(self): 42 | try: 43 | self.status("Removing previous builds…") 44 | rmtree(os.path.join(here, "dist")) 45 | except OSError: 46 | pass 47 | 48 | self.status("Building Source and Wheel (universal) distribution…") 49 | os.system("{0} setup.py sdist bdist_wheel".format(sys.executable)) 50 | 51 | self.status("Uploading the package to PyPI via Twine…") 52 | os.system("twine upload dist/*") 53 | 54 | self.status("Pushing git tags…") 55 | # os.system('git tag v{0}'.format(about['__version__'])) 56 | os.system("git push --tags") 57 | 58 | sys.exit() 59 | 60 | 61 | with open("README.md") as readme_file: 62 | readme = readme_file.read() 63 | 64 | with open("HISTORY.md") as history_file: 65 | history = history_file.read() 66 | 67 | setup( 68 | name=NAME, 69 | version=VERSION, 70 | python_requires=REQUIRES_PYTHON, 71 | description=DESCRIPTION, 72 | long_description=readme + "\n\n" + history, 73 | long_description_content_type="text/markdown", 74 | author=AUTHOR, 75 | author_email=EMAIL, 76 | url=URL, 77 | packages=find_packages(exclude=("tests",)), 78 | entry_points={}, 79 | install_requires=REQUIRED, 80 | extras_require=EXTRAS, 81 | include_package_data=True, 82 | license="BSD-3-Clause", 83 | zip_safe=False, 84 | keywords=["metering", "amr", "iec62056-21"], 85 | classifiers=[], 86 | # $ setup.py publish support. 87 | cmdclass={"upload": UploadCommand}, 88 | ) 89 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwitab/iec62056-21/09d9acfa43dffdbbf602caf1e5d638cca1e571f6/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_bcc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from iec62056_21.utils import calculate_bcc, add_bcc 4 | 5 | 6 | class TestBcc: 7 | def test_bcc_bytes1(self): 8 | data = bytes.fromhex("01573202433030332839313033323430393232333929031b") 9 | correct_bcc = chr(data[-1]).encode("latin-1") 10 | bcc = calculate_bcc(data[1:-1]) 11 | assert bcc == correct_bcc 12 | 13 | def test_bcc_bytes_2(self): 14 | data = b"\x01P0\x02(1234567)\x03P" 15 | correct_bcc = chr(data[-1]).encode("latin-1") 16 | bcc = calculate_bcc(data[1:-1]) 17 | assert bcc == correct_bcc 18 | 19 | def test_bcc_string(self): 20 | data = "\x01P0\x02(1234567)\x03P" 21 | correct_bcc = data[-1] 22 | bcc = calculate_bcc(data[1:-1]) 23 | assert bcc == correct_bcc 24 | 25 | def test_add_bcc1(self): 26 | data = "\x01P0\x02(1234567)\x03" 27 | correct_data = "\x01P0\x02(1234567)\x03P" 28 | with_bcc = add_bcc(data) 29 | assert with_bcc == correct_data 30 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from iec62056_21 import exceptions, client, transports 3 | 4 | 5 | class TestIec6205621Client: 6 | def test_with_no_address_when_required_raises_client_error(self): 7 | with pytest.raises(exceptions.Iec6205621ClientError): 8 | c = client.Iec6205621Client.with_tcp_transport(("192.168.1.1", 5000)) 9 | 10 | def test_can_create_client_with_tcp_transport(self): 11 | c = client.Iec6205621Client.with_tcp_transport( 12 | "192.168.1.1", device_address="00000000" 13 | ) 14 | 15 | def test_no_address_when_required_raises_client_error(self): 16 | trans = transports.TcpTransport(address=("192.168.1.1", 5000)) 17 | with pytest.raises(exceptions.Iec6205621ClientError): 18 | c = client.Iec6205621Client(transport=trans) 19 | 20 | def test_can_create_client_tcp_transport(self): 21 | trans = transports.TcpTransport(address=("192.168.1.1", 5000)) 22 | c = client.Iec6205621Client(transport=trans, device_address="00000000") 23 | -------------------------------------------------------------------------------- /tests/test_data.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from iec62056_21 import messages, exceptions, constants 4 | 5 | 6 | class TestDataSets: 7 | 8 | data_set_with_unit = "3.1.0(100*kWh)" 9 | data_set_without_unit = "3.1.0(100)" 10 | not_valid_data = '"Tralalalala' 11 | 12 | def test_from_string_with_unit(self): 13 | ds = messages.DataSet.from_representation(self.data_set_with_unit) 14 | assert ds.value == "100" 15 | assert ds.address == "3.1.0" 16 | assert ds.unit == "kWh" 17 | 18 | def test_from_bytes_with_unit(self): 19 | ds_bytes = self.data_set_with_unit.encode("latin-1") 20 | ds = messages.DataSet.from_representation(self.data_set_with_unit) 21 | assert ds.value == "100" 22 | assert ds.address == "3.1.0" 23 | assert ds.unit == "kWh" 24 | 25 | def test_to_string_with_unit(self): 26 | ds = messages.DataSet(value="100", address="3.1.0", unit="kWh") 27 | assert ds.to_representation() == self.data_set_with_unit 28 | 29 | def test_to_byte_with_unit(self): 30 | ds = messages.DataSet(value="100", address="3.1.0", unit="kWh") 31 | assert ds.to_bytes() == self.data_set_with_unit.encode(constants.ENCODING) 32 | 33 | def test_from_string_without_unit(self): 34 | ds = messages.DataSet.from_representation(self.data_set_without_unit) 35 | assert ds.value == "100" 36 | assert ds.address == "3.1.0" 37 | assert ds.unit is None 38 | 39 | def test_from_bytes_without_unit(self): 40 | ds_bytes = self.data_set_without_unit.encode("latin-1") 41 | ds = messages.DataSet.from_representation(self.data_set_without_unit) 42 | assert ds.value == "100" 43 | assert ds.address == "3.1.0" 44 | assert ds.unit is None 45 | 46 | def test_to_string_without_unit(self): 47 | ds = messages.DataSet(value="100", address="3.1.0", unit=None) 48 | assert ds.to_representation() == self.data_set_without_unit 49 | 50 | def test_to_byte_without_unit(self): 51 | ds = messages.DataSet(value="100", address="3.1.0", unit=None) 52 | assert ds.to_bytes() == self.data_set_without_unit.encode(constants.ENCODING) 53 | 54 | def test_invalid_data(self): 55 | with pytest.raises(exceptions.Iec6205621ParseError): 56 | ds = messages.DataSet.from_representation(self.not_valid_data) 57 | 58 | 59 | class TestBase: 60 | def test_to_bytes(self): 61 | with pytest.raises(NotImplementedError): 62 | messages.Iec6205621Data().to_bytes() 63 | 64 | def test_from_bytes(self): 65 | with pytest.raises(NotImplementedError): 66 | messages.Iec6205621Data().from_bytes(b"1235") 67 | 68 | 69 | class TestDataLine: 70 | def test_from_representation(self): 71 | string_data = "12(12*kWh)13(13*kWh)14(14*kwh)" 72 | 73 | dl = messages.DataLine.from_representation(string_data) 74 | 75 | assert len(dl.data_sets) == 3 76 | assert dl.data_sets[0].value == "12" 77 | assert dl.data_sets[0].address == "12" 78 | assert dl.data_sets[0].unit == "kWh" 79 | 80 | def test_to_representation(self): 81 | dl = messages.DataLine( 82 | data_sets=[ 83 | messages.DataSet(address="3:14", value="314", unit="kWh"), 84 | messages.DataSet(address="4:15", value="415", unit="kWh"), 85 | ] 86 | ) 87 | 88 | rep = dl.to_representation() 89 | 90 | assert rep == "3:14(314*kWh)4:15(415*kWh)" 91 | 92 | 93 | class TestDataBlock: 94 | def test_from_representation_single_line(self): 95 | string_data = "12(12*kWh)13(13*kWh)14(14*kwh)\r\n" 96 | 97 | db = messages.DataBlock.from_representation(string_data) 98 | 99 | assert len(db.data_lines) == 1 100 | 101 | def test_from_representation_several_lines(self): 102 | string_data = ( 103 | "12(12*kWh)13(13*kWh)14(14*kwh)\r\n" 104 | "12(12*kWh)13(13*kWh)14(14*kwh)\r\n" 105 | "12(12*kWh)13(13*kWh)14(14*kwh)\r\n" 106 | ) 107 | db = messages.DataBlock.from_representation(string_data) 108 | 109 | assert len(db.data_lines) == 3 110 | 111 | def test_to_representation_several_lines(self): 112 | 113 | db = messages.DataBlock( 114 | data_lines=[ 115 | messages.DataLine( 116 | data_sets=[ 117 | messages.DataSet(address="3:14", value="314", unit="kWh"), 118 | messages.DataSet(address="4:15", value="415", unit="kWh"), 119 | ] 120 | ), 121 | messages.DataLine( 122 | data_sets=[ 123 | messages.DataSet(address="3:14", value="314", unit="kWh"), 124 | messages.DataSet(address="4:15", value="415", unit="kWh"), 125 | ] 126 | ), 127 | messages.DataLine( 128 | data_sets=[ 129 | messages.DataSet(address="3:14", value="314", unit="kWh"), 130 | messages.DataSet(address="4:15", value="415", unit="kWh"), 131 | ] 132 | ), 133 | ] 134 | ) 135 | 136 | assert db.to_representation() == ( 137 | "3:14(314*kWh)4:15(415*kWh)\r\n" 138 | "3:14(314*kWh)4:15(415*kWh)\r\n" 139 | "3:14(314*kWh)4:15(415*kWh)\r\n" 140 | ) 141 | 142 | def test_to_representation_single_line(self): 143 | db = messages.DataBlock( 144 | data_lines=[ 145 | messages.DataLine( 146 | data_sets=[ 147 | messages.DataSet(address="3:14", value="314", unit="kWh"), 148 | messages.DataSet(address="4:15", value="415", unit="kWh"), 149 | ] 150 | ) 151 | ] 152 | ) 153 | 154 | assert db.to_representation() == "3:14(314*kWh)4:15(415*kWh)\r\n" 155 | 156 | 157 | class TestAnswerDataMessage: 158 | def test_from_representation(self): 159 | data = "\x023:171.0(0)\x03\x12" 160 | 161 | am = messages.AnswerDataMessage.from_representation(data) 162 | 163 | assert am.data_block.data_lines[0].data_sets[0].value == "0" 164 | assert am.data_block.data_lines[0].data_sets[0].address == "3:171.0" 165 | assert am.data[0].value == "0" 166 | assert am.data[0].address == "3:171.0" 167 | 168 | def test_from_representation_invalid_bcc_raises_value_error(self): 169 | data = "\x023:171.0(0)\x03\x11" 170 | with pytest.raises(ValueError): 171 | am = messages.AnswerDataMessage.from_representation(data) 172 | 173 | def test_to_representation(self): 174 | db = messages.DataBlock( 175 | data_lines=[ 176 | messages.DataLine( 177 | data_sets=[ 178 | messages.DataSet(address="3:14", value="314", unit="kWh"), 179 | messages.DataSet(address="4:15", value="415", unit="kWh"), 180 | ] 181 | ) 182 | ] 183 | ) 184 | 185 | am = messages.AnswerDataMessage(data_block=db) 186 | 187 | assert am.to_representation() == "\x023:14(314*kWh)4:15(415*kWh)\r\n\x03\x04" 188 | 189 | 190 | class TestReadoutDataMessage: 191 | def test_to_representation(self): 192 | rdm = messages.ReadoutDataMessage( 193 | data_block=messages.DataBlock( 194 | data_lines=[ 195 | messages.DataLine( 196 | data_sets=[ 197 | messages.DataSet(address="3:14", value="314", unit="kWh"), 198 | messages.DataSet(address="4:15", value="415", unit="kWh"), 199 | ] 200 | ) 201 | ] 202 | ) 203 | ) 204 | 205 | assert rdm.to_representation() == '\x023:14(314*kWh)4:15(415*kWh)\r\n!\r\n\x03"' 206 | 207 | def test_from_representation(self): 208 | 209 | rdm = messages.ReadoutDataMessage.from_representation( 210 | '\x023:14(314*kWh)4:15(415*kWh)\r\n!\r\n\x03"' 211 | ) 212 | print(rdm) 213 | assert len(rdm.data_block.data_lines) == 1 214 | assert len(rdm.data_block.data_lines[0].data_sets) == 2 215 | 216 | def test_invalid_bcc_raises_error(self): 217 | with pytest.raises(ValueError): 218 | rdm = messages.ReadoutDataMessage.from_representation( 219 | "\x023:14(314*kWh)4:15(415*kWh)\r\n!\r\n\x03x" 220 | ) 221 | 222 | 223 | class TestCommandMessage: 224 | def test_command_message_to_representation(self): 225 | cm = messages.CommandMessage( 226 | command="R", 227 | command_type="1", 228 | data_set=messages.DataSet(address="1.8.0", value="1"), 229 | ) 230 | 231 | assert cm.to_representation() == "\x01R1\x021.8.0(1)\x03k" 232 | 233 | def test_from_representation(self): 234 | data = "\x01P0\x02(1234567)\x03P" 235 | cm = messages.CommandMessage.from_representation(data) 236 | 237 | assert cm.command == "P" 238 | assert cm.command_type == "0" 239 | assert cm.data_set.value == "1234567" 240 | assert cm.data_set.address is None 241 | assert cm.data_set.unit is None 242 | 243 | def test_invalid_command_raises_value_error(self): 244 | with pytest.raises(ValueError): 245 | cm = messages.CommandMessage( 246 | command="X", 247 | command_type="1", 248 | data_set=messages.DataSet(address="1.8.0", value="1"), 249 | ) 250 | 251 | def test_invalid_command_type_raises_value_error(self): 252 | with pytest.raises(ValueError): 253 | cm = messages.CommandMessage( 254 | command="R", 255 | command_type="12", 256 | data_set=messages.DataSet(address="1.8.0", value="1"), 257 | ) 258 | 259 | def test_to_representation_without_data_set(self): 260 | # like the break command 261 | 262 | break_msg = messages.CommandMessage( 263 | command="B", command_type="0", data_set=None 264 | ) 265 | 266 | assert break_msg.to_representation() == "\x01B0\x03q" 267 | 268 | def test_from_representation_invalid_bcc_raise_value_error(self): 269 | data = "\x01P0\x02(1234567)\x03X" 270 | with pytest.raises(ValueError): 271 | cm = messages.CommandMessage.from_representation(data) 272 | 273 | def test_for_single_read(self): 274 | cm = messages.CommandMessage.for_single_read(address="1.8.0") 275 | 276 | assert cm.command == "R" 277 | assert cm.command_type == "1" 278 | assert cm.data_set.address == "1.8.0" 279 | 280 | cm = messages.CommandMessage.for_single_read( 281 | address="1.8.0", additional_data="1" 282 | ) 283 | 284 | assert cm.command == "R" 285 | assert cm.command_type == "1" 286 | assert cm.data_set.address == "1.8.0" 287 | assert cm.data_set.value == "1" 288 | 289 | def test_for_single_write(self): 290 | cm = messages.CommandMessage.for_single_write(address="1.8.0", value="123") 291 | 292 | assert cm.command == "W" 293 | assert cm.command_type == "1" 294 | assert cm.data_set.address == "1.8.0" 295 | assert cm.data_set.value == "123" 296 | 297 | 298 | class TestRequestMessage: 299 | def test_to_representation(self): 300 | rm = messages.RequestMessage(device_address="45678903") 301 | 302 | assert rm.to_representation() == "/?45678903!\r\n" 303 | 304 | def test_to_representation_without_address(self): 305 | rm = messages.RequestMessage() 306 | 307 | assert rm.to_representation() == "/?!\r\n" 308 | 309 | def test_from_representation(self): 310 | 311 | in_data = "/?45678903!\r\n" 312 | 313 | rm = messages.RequestMessage.from_representation(in_data) 314 | 315 | assert rm.device_address == "45678903" 316 | 317 | 318 | class TestAckOptionSelectMessage: 319 | def test_to_representation(self): 320 | aosm = messages.AckOptionSelectMessage(mode_char="1", baud_char="5") 321 | 322 | assert aosm.to_representation() == "\x06051\r\n" 323 | 324 | def test_from_representation(self): 325 | aosm = messages.AckOptionSelectMessage.from_representation("\x06051\r\n") 326 | 327 | assert aosm.mode_char == "1" 328 | assert aosm.baud_char == "5" 329 | 330 | 331 | class TestIdentificationMessage: 332 | def test_to_representation(self): 333 | 334 | im = messages.IdentificationMessage( 335 | identification="2EK280", manufacturer="Els", switchover_baudrate_char="6" 336 | ) 337 | 338 | assert im.to_representation() == "/Els6\\2EK280\r\n" 339 | 340 | def test_from_representation(self): 341 | im = messages.IdentificationMessage.from_representation("/Els6\\2EK280\r\n") 342 | 343 | assert im.identification == "2EK280" 344 | assert im.manufacturer == "Els" 345 | assert im.switchover_baudrate_char == "6" 346 | --------------------------------------------------------------------------------