├── .flake8 ├── .github └── workflows │ ├── docs.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── HISTORY.md ├── LICENSE ├── README.md ├── codecov.yml ├── dlms-logo.png ├── dlms_cosem ├── __init__.py ├── a_xdr.py ├── ber.py ├── client.py ├── connection.py ├── cosem │ ├── __init__.py │ ├── association.py │ ├── attribute_with_selection.py │ ├── base.py │ ├── capture_object.py │ ├── obis.py │ ├── profile_generic.py │ └── selective_access.py ├── crc.py ├── dlms_data.py ├── enumerations.py ├── exceptions.py ├── hdlc │ ├── __init__.py │ ├── address.py │ ├── connection.py │ ├── exceptions.py │ ├── fields.py │ ├── frames.py │ ├── state.py │ └── validators.py ├── io.py ├── parsers.py ├── protocol │ ├── __init__.py │ ├── acse │ │ ├── __init__.py │ │ ├── aare.py │ │ ├── aarq.py │ │ ├── base.py │ │ ├── rlre.py │ │ ├── rlrq.py │ │ └── user_information.py │ ├── wrappers.py │ └── xdlms │ │ ├── __init__.py │ │ ├── action.py │ │ ├── base.py │ │ ├── confirmed_service_error.py │ │ ├── conformance.py │ │ ├── data_notification.py │ │ ├── exception_response.py │ │ ├── general_global_cipher.py │ │ ├── get.py │ │ ├── initiate_request.py │ │ ├── initiate_response.py │ │ ├── invoke_id_and_priority.py │ │ └── set.py ├── security.py ├── state.py ├── time.py └── utils.py ├── docs ├── CNAME ├── api_design.md ├── connect_to_your_meter.md ├── dlms_cosem.md └── index.md ├── examples ├── associations_list.py ├── dlms_with_hdlc_example.py ├── dlms_with_tcp_example.py └── parse_norwegian_han.py ├── mkdocs.yml ├── pyproject.toml ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── test_a_xdr.py ├── test_asce ├── __init__.py ├── test_aare.py ├── test_aarq.py ├── test_conformance.py ├── test_rlre.py └── test_rlrq.py ├── test_blocking_tcp_transport.py ├── test_clients ├── __init__.py └── test_dlms_client.py ├── test_cosem.py ├── test_dlms_connection.py ├── test_dlms_data.py ├── test_dlms_state.py ├── test_general_global_cipher.py ├── test_hdlc ├── __init__.py └── test_hdlc.py ├── test_parsers.py ├── test_security.py ├── test_time.py ├── test_udp_message.py └── test_xdlms ├── __init__.py ├── test_action.py ├── test_data_notification.py ├── test_get.py ├── test_initiate.py ├── test_selective_access.py └── test_set.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503, F403, F401, F405, F541 3 | max-line-length = 89 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: build-docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.8 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install mkdocs-material 19 | - run: mkdocs gh-deploy --force 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: ["3.13", "3.12", "3.11", "3.10", "3.9",] 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pytest pytest-cov 21 | pip install -e . 22 | 23 | - name: Test with pytest 24 | run: | 25 | python -m pytest -v --cov=dlms_cosem 26 | 27 | # - name: Submit coverage report to Codecov 28 | # # only submit to Codecov once 29 | # if: ${{ matrix.python-version == 3.10 }} 30 | # uses: codecov/codecov-action@v4 31 | # with: 32 | # fail_ci_if_error: true 33 | # token: ${{ secrets.CODECOV_TOKEN }} 34 | # verbose: true 35 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 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 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # pycharm 107 | .idea/ 108 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/isort 3 | rev: 5.7.0 4 | hooks: 5 | - id: isort # needs to run before black 6 | language_version: python3.6 7 | args: ["--profile", "black", "--filter-files"] 8 | - repo: https://github.com/psf/black 9 | rev: 20.8b1 10 | hooks: 11 | - id: black 12 | language_version: python3.6 13 | 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v2.3.0 16 | hooks: 17 | - id: check-yaml 18 | - id: end-of-file-fixer 19 | - id: trailing-whitespace 20 | - id: flake8 21 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | All notable changes to this project will be documented in this file. 4 | 5 | 6 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 7 | and this project adheres to [Calendar Versioning](https://calver.org/) 8 | 9 | ## Unreleased 10 | 11 | 12 | ### Added 13 | * `use_rlrq_rlre` added to DlmsConnectionSettings. If `False` no ReleaseRequest is sent to server/device and lower 14 | layer can be disconnected right away. 15 | 16 | ### Changed 17 | 18 | ### Deprecated 19 | 20 | ### Removed 21 | 22 | ### Fixed 23 | 24 | ### Security 25 | 26 | 27 | ## 24.1.0 - 2024-01-22 28 | 29 | ### Added 30 | * Support for HDLC over TCP via composition of IO-implementation separate from 31 | transport implementation. 32 | * Support for LLS (Low Level Security) 33 | * Support for HLS (method 2) using common method of AES128-ECB. 34 | * Structlog logging. 35 | * Implemented VisibleStringData. 36 | * DlmsConnectionSettings to handle manufacturer specific quirks in communication. 37 | 38 | ### Changed 39 | * Changed License from MIT to BSL v1.1 40 | * Refactor of authentication to make it simpler support more authentication types. And make 41 | it possible to supply manufacturer specific implementations. 42 | 43 | ### Removed 44 | * Shorthand class methods to create DlmsClients. Now composition has to be used. 45 | 46 | ### Fixed 47 | * Error in DataArray dlms data type 48 | 49 | 50 | ## [21.3.2] - 2021-11-07 51 | 52 | ## Changed 53 | 54 | Updated dependencies and made it a bit more lenient so installing in other environments 55 | will allow for more versions. 56 | 57 | ## [21.3.1] - 2021-06-14 58 | 59 | ### Added 60 | 61 | * To handle the more complicated parsing problem of GET.WITH_LIST with compound data 62 | elements a new parser, DlmsDataParser, was added that focuses on only A-XDR DLMS data. 63 | Hopefully this can be be used instead of the A-XDR Parser when the parsing of ACSE 64 | services APDUs is built away 65 | 66 | ## [21.3.0] - 2021-06-08 67 | 68 | 69 | ### Added 70 | 71 | * Added HDLC UnnumberedInformationFrame. 72 | * Ability to set timeout of transport layer at client level. 73 | * A simpler way to change client address and invocation counter of a `DlmsClient` to 74 | that reuseing a connection goes smoother 75 | * Added `from_string` on `Obis`that can parse any viable string as OBIS. 76 | * Added GET.WITH_LIST service. 77 | 78 | ### Changed 79 | 80 | * Renamed classes to exclude `Apdu` in class names. To have it consistent over the 81 | project. 82 | * Simplified DataNotification 83 | * Improved handling of pre-established associations 84 | * Using the wrong data to decrypt now raises `DecryptionError` instead of InvalidTag 85 | * The `to_string` method on `Obis` now returns in the format `1-8:1.8.0.255` with a 86 | possible override of the separator. 87 | 88 | ### Removed 89 | 90 | * Removed the `from_dotted`, `dotted_repr` and `verbose_repr` from `Obis` 91 | 92 | 93 | ### Fixed 94 | 95 | * Some DLMS over TCP implementations will return partial data. The 96 | `BlockingTcpTransport` now keeps on trying to read the data until all data is 97 | received. Fixes [#35](https://github.com/pwitab/dlms-cosem/issues/35). 98 | * Fixed a bug in the HDLC layer that prevented correct sending of segmented information 99 | frames. 100 | 101 | 102 | ## [21.2.2] - 2021-03-02 103 | 104 | ### Fixed 105 | 106 | * Fixed missing state management for general ACTION usage 107 | 108 | ## [21.2.1] - 2021-02-18 109 | 110 | ### Fixed 111 | 112 | * Fixed [#23](https://github.com/pwitab/dlms-cosem/issues/23). Typo in A-XDR Parser. 113 | Just referenced the function and did not call it. Now DLMS data is interpreted 114 | correctly. 115 | 116 | * Fixed [#20](https://github.com/pwitab/dlms-cosem/issues/20). It was possible that not 117 | calling the .shutdown() on socket before disconnecting made remote modems on meters, 118 | that have an embedded TCP/IP stack, keep the socket open and blocking subsequent calls. 119 | 120 | ## [21.2.0] - 2021-01-28 121 | 122 | ### Added 123 | 124 | * Support for basic SET service. No support for WITH_LIST or service specific block 125 | transfer 126 | 127 | ## [21.1.2] - 2021-01-22 128 | 129 | ### Fixed 130 | 131 | * The standard DLMS way of dealing with timezones in datetime are via UTC offset. But 132 | the offset is the deviation from normal time to UTC not deviation from UTC. This 133 | results in -60 min deviation for UTC+01:00 for example. Previous solution assumed 134 | 60 min for UTC+01:00. Solved by negating all values for offset. 135 | Note that some DLMS companion standards handles the offset the previous way and in 136 | the future there will be need to handle both ways correctly. 137 | 138 | * Included typing-extensions in required packages. 139 | 140 | ## [21.1.1] - 2021-01-13 141 | 142 | ### Added 143 | 144 | * Better handling of TCP errors in `BlockingTcpTransport` 145 | 146 | ### Changed 147 | 148 | * It is now explicitly possible to connect and disconnect a transport in the 149 | `DlmsClient` instead of it being done automatically in `.associate()` and 150 | `.release_association()`. Context manager `.session()` works the same. 151 | 152 | * Client to server challenge of DlmsConnection is always created independent of auth 153 | method. But only used if needed. 154 | 155 | ### Removed 156 | 157 | * Removed conformance validation in DlmsConnection. It seems like meters don't always 158 | follow it so better to leave it up to the client. 159 | 160 | 161 | 162 | ## [21.1.0] - 2021-01-12 163 | 164 | ### Added 165 | 166 | * HDLC transport implementation 167 | * TCP transport implementation 168 | * DlMS client implementation 169 | * Support for Get service including service specific block transfer 170 | * Support for selective access via range descriptor 171 | * Support for HLS authentication using HLS-GMAC. 172 | * Support for GlobalCiphering 173 | * Parsing of ProfileGeneric buffer 174 | 175 | ### Changed 176 | 177 | * Changed project versioning scheme to Calendar versioning 178 | 179 | 180 | ## v0.0.2 181 | 182 | 183 | ### Changed 184 | 185 | - UDP messages are now based WrapperProtocolDataUnit to be able to reuse 186 | WrapperHeader for TCP messages. 187 | - Parsing of DLMS APDUs 188 | 189 | 190 | ### v0.0.1 191 | 192 | 193 | Initial implementation. 194 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Business Source License 1.1 2 | 3 | Parameters 4 | 5 | Licensor: Palmlund Wahlgren Innovative Technology AB 6 | 7 | Licensed Work: The python library `dlms-cosem` 8 | The Licensed Work is (c) 2024 Palmlund Wahlgren 9 | Innovative Technology AB. 10 | 11 | Additional Use Grant: You may make use of the Licensed Work for any Permitted Purpose other than a Competing Use. 12 | A Competing Use means use of the Licensed Work in or for a commercial product or service that 13 | competes with the Licensed Work or any other product or service we offer using the Licensed Work 14 | as of the date we make the Software available. 15 | 16 | Competing Uses specifically include using the Licensed Work: 17 | 18 | 1. as a substitute for any of our products or services; 19 | 20 | 2. in a way that exposes the APIs of the Licensed Work; and 21 | 22 | 3. in a product or service that offers the same or substantially similar 23 | functionality to the Licensed Work. 24 | 25 | Permitted Purposes specifically include using the Software: 26 | 27 | 1. for your internal use and access; 28 | 29 | 2. for non-commercial education; and 30 | 31 | 3. for non-commercial research. 32 | 33 | 34 | Change Date: 4 years from release date 35 | 36 | Change License: Apache License, Version 2.0 37 | 38 | For information about alternative licensing arrangements for the library, 39 | please contact us at info@pwit.se. 40 | 41 | Notice 42 | 43 | The Business Source License (this document, or the "License") is not an Open 44 | Source license. However, the Licensed Work will eventually be made available 45 | under an Open Source License, as stated in this License. 46 | 47 | License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. 48 | "Business Source License" is a trademark of MariaDB Corporation Ab. 49 | 50 | ----------------------------------------------------------------------------- 51 | 52 | Business Source License 1.1 53 | 54 | Terms 55 | 56 | The Licensor hereby grants you the right to copy, modify, create derivative 57 | works, redistribute, and make non-production use of the Licensed Work. The 58 | Licensor may make an Additional Use Grant, above, permitting limited 59 | production use. 60 | 61 | Effective on the Change Date, or the fourth anniversary of the first publicly 62 | available distribution of a specific version of the Licensed Work under this 63 | License, whichever comes first, the Licensor hereby grants you rights under 64 | the terms of the Change License, and the rights granted in the paragraph 65 | above terminate. 66 | 67 | If your use of the Licensed Work does not comply with the requirements 68 | currently in effect as described in this License, you must purchase a 69 | commercial license from the Licensor, its affiliated entities, or authorized 70 | resellers, or you must refrain from using the Licensed Work. 71 | 72 | All copies of the original and modified Licensed Work, and derivative works 73 | of the Licensed Work, are subject to this License. This License applies 74 | separately for each version of the Licensed Work and the Change Date may vary 75 | for each version of the Licensed Work released by Licensor. 76 | 77 | You must conspicuously display this License on each original or modified copy 78 | of the Licensed Work. If you receive the Licensed Work in original or 79 | modified form from a third party, the terms and conditions set forth in this 80 | License apply to your use of that work. 81 | 82 | Any use of the Licensed Work in violation of this License will automatically 83 | terminate your rights under this License for the current and all other 84 | versions of the Licensed Work. 85 | 86 | This License does not grant you any right in any trademark or logo of 87 | Licensor or its affiliates (provided that you may use a trademark or logo of 88 | Licensor as expressly required by this License). 89 | 90 | TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON 91 | AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, 92 | EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF 93 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND 94 | TITLE. 95 | 96 | MariaDB hereby grants you permission to use this License’s text to license 97 | your works, and to refer to it using the trademark "Business Source License", 98 | as long as you comply with the Covenants of Licensor below. 99 | 100 | Covenants of Licensor 101 | 102 | In consideration of the right to use this License’s text and the "Business 103 | Source License" name and trademark, Licensor covenants to MariaDB, and to all 104 | other recipients of the licensed work to be provided by Licensor: 105 | 106 | 1. To specify as the Change License the GPL Version 2.0 or any later version, 107 | or a license that is compatible with GPL Version 2.0 or a later version, 108 | where "compatible" means that software provided under the Change License can 109 | be included in a program with software provided under GPL Version 2.0 or a 110 | later version. Licensor may specify additional Change Licenses without 111 | limitation. 112 | 113 | 2. To either: (a) specify an additional grant of rights to use that does not 114 | impose any additional restriction on the right granted in this License, as 115 | the Additional Use Grant; or (b) insert the text "None". 116 | 117 | 3. To specify a Change Date. 118 | 119 | 4. Not to modify this License in any other way. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # A Python library for DLMS/COSEM. 3 | 4 | [![codecov](https://codecov.io/gh/pwitab/dlms-cosem/branch/master/graph/badge.svg?token=RO37L11VQJ)](https://codecov.io/gh/pwitab/dlms-cosem) 5 | ![run-tests](https://github.com/pwitab/dlms-cosem/workflows/run-tests/badge.svg) 6 | ![build-docs](https://github.com/pwitab/dlms-cosem/workflows/build-docs/badge.svg) 7 | 8 | dlms_logo 9 | 10 | # Installation 11 | 12 | ``` 13 | pip install dlms-cosem 14 | ``` 15 | 16 | # Documentation 17 | 18 | Full documentation can be found at [www.dlms.dev](https://www.dlms.dev) 19 | 20 | # About 21 | 22 | `dlms-cosem` is designed to be a tool with a simple API for working with DLMS/COSEM 23 | enabled energy meters. It provides the lowest level function, as protocol state 24 | management, APDU encoding/decoding, APDU encryption/decryption. 25 | 26 | The library aims to provide a [sans-io](https://sans-io.readthedocs.io/) implementation 27 | of the DLMS/COSEM protocol so that the protocol code can be reused with several 28 | io-paradigms. As of now we provide a simple client implementation based on 29 | blocking I/O. This can be used over either a serial interface with HDLC or over TCP. 30 | 31 | We have not implemented full support to be able to build a server (meter) emulator. If 32 | this is a use-case you need, consider sponsoring the development and contact us. 33 | 34 | # Supported features 35 | 36 | * AssociationRequest and AssociationRelease 37 | * GET, GET.WITH_BLOCK, GET.WITH_LIST 38 | * SET 39 | * ACTION 40 | * DataNotification 41 | * GlobalCiphering - Authenticated and Encrypted. 42 | * HLS-GMAC, LLS, HLS-Common auth 43 | * Selective access via RangeDescriptor 44 | * Parsing of ProfileGeneric buffers 45 | 46 | # Example use: 47 | 48 | A simple example of reading invocation counters using a public client: 49 | 50 | ```python 51 | from dlms_cosem.client import DlmsClient 52 | from dlms_cosem.io import TcpTransport, BlockingTcpIO 53 | from dlms_cosem.security import NoSecurityAuthentication 54 | from dlms_cosem import enumerations, cosem 55 | 56 | tcp_io = BlockingTcpIO(host="localhost", port=4059) 57 | tcp_transport = TcpTransport(io=tcp_io, server_logical_address=1, client_logical_address=16) 58 | client = DlmsClient(transport=tcp_transport, authentication=NoSecurityAuthentication()) 59 | with client.session() as dlms_client: 60 | data = dlms_client.get( 61 | cosem.CosemAttribute(interface=enumerations.CosemInterface.DATA, 62 | instance=cosem.Obis(0, 0, 0x2B, 1, 0), attribute=2, )) 63 | ``` 64 | 65 | 66 | Look at the different files in the `examples` folder get a better feel on how to fully 67 | use the library. 68 | 69 | # Supported meters 70 | 71 | Technically we aim to support any DLMS enabled meter. The library is implementing all 72 | the low level DLMS, and you might need an abstraction layer to support everything in 73 | your meter. 74 | 75 | DLMS/COSEM specifies many ways of performing tasks on a meter. It is 76 | customary that a meter also adheres to a companion standard. In the companion standard 77 | it is defined exactly how certain use-cases are to be performed and how data is modeled. 78 | 79 | Examples of companion standards are: 80 | * DSMR (Netherlands) 81 | * IDIS (all Europe) 82 | * UNI/TS 11291 (Italy) 83 | 84 | On top of it all your DSO (Distribution Service Operator) might have ordered their 85 | meters with extra functionality or reduced functionality from one of the companion 86 | standards. 87 | 88 | We have some meters we have run tests on or know the library is used for in production 89 | 90 | * Pietro Fiorentini RSE 1,2 LA N1. Italian gas meter 91 | * Iskraemeco AM550. IDIS compliant electricity meter. 92 | * Itron SL7000 93 | * Hexing HXF300 94 | 95 | 96 | # License 97 | 98 | 99 | *The `dlms-cosem` library is released under the Business Source License 1.1 . 100 | It is not a fully Open Source License but will eventually be made available under an Open Source License 101 | (Apache License, Version 2.0), as stated in the license document.* 102 | 103 | Our goal with this licence is to provide enough freedom for you to use and learn from the software without 104 | [harmful free-riding](https://en.wikipedia.org/wiki/Free-rider_problem). 105 | 106 | --- 107 | 108 | You may make use of the Licensed Work for any Permitted Purpose other than a Competing Use. 109 | A Competing Use means use of the Licensed Work in or for a commercial product or service that 110 | competes with the Licensed Work or any other product or service we offer using the Licensed Work 111 | as of the date we make the Software available. 112 | 113 | Competing Uses specifically include using the Licensed Work: 114 | 115 | 1. as a substitute for any of our products or services; 116 | 117 | 2. in a way that exposes the APIs of the Licensed Work; and 118 | 119 | 3. in a product or service that offers the same or substantially similar 120 | functionality to the Licensed Work. 121 | 122 | Permitted Purposes specifically include using the Software: 123 | 124 | 1. for your internal use and access; 125 | 126 | 2. for non-commercial education; and 127 | 128 | 3. for non-commercial research. 129 | 130 | For information about alternative licensing arrangements or questions about permitted use of the library, 131 | please contact us at `info(at)pwit.se`. 132 | 133 | # Development 134 | 135 | This library is developed by Palmlund Wahlgren Innovative Technology AB. We are 136 | based in Sweden and are members of the DLMS User Association. 137 | 138 | If you find a bug please raise an issue on Github. 139 | 140 | We add features depending on our own, and our clients use cases. If you 141 | need a feature implemented please contact us. 142 | 143 | # Training / Consultancy / Commercial Support / Services 144 | 145 | We offer consultancy service and training services around this library and general DLMS/COSEM. 146 | If you are interested in our services just reach at `ìnfo(at)pwit.se` 147 | 148 | The library is an important part of our [Smart meter platform Utilitarian, https://utilitarian.io](https://utilitarian.io). If you need to 149 | collect data from a lot of DLMS devices or meters, deploying Utilitarian might be the smoothest 150 | solution for you. 151 | 152 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/codecov.yml -------------------------------------------------------------------------------- /dlms-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/dlms-logo.png -------------------------------------------------------------------------------- /dlms_cosem/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/dlms_cosem/__init__.py -------------------------------------------------------------------------------- /dlms_cosem/ber.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | 4 | class BER: 5 | 6 | """ 7 | BER encoding consists of a TAG ID, Length and data 8 | Simple implementation that calculates the lenght. 9 | """ 10 | 11 | @staticmethod 12 | def encode(tag: Union[int, bytes], data: Union[bytearray, bytes]): 13 | 14 | if isinstance(tag, int): 15 | # Simplification since we now just use ints as tags when they are single 16 | # bytes. 17 | _tag_bytes = tag.to_bytes(1, "big") 18 | else: 19 | _tag_bytes = tag 20 | 21 | if data is None: 22 | return b"" 23 | 24 | if not isinstance(data, (bytes, bytearray)): 25 | raise ValueError( 26 | f"BER encoding requires bytes or bytearray, got {data!r} of {type(data)}" 27 | ) 28 | 29 | length = len(data).to_bytes(1, "big") 30 | if length == 0: 31 | return b"" 32 | 33 | return b"".join([_tag_bytes, length, data]) 34 | 35 | @staticmethod 36 | def decode(_bytes: bytes, tag_length: int = 1) -> Tuple[bytes, int, bytes]: 37 | input = bytearray(_bytes) 38 | tag = b"".join([input.pop(0).to_bytes(1, "big") for _ in range(tag_length)]) 39 | length = input.pop(0) 40 | data = input 41 | if len(data) != length: 42 | raise ValueError( 43 | f"BER-decoding failed. Length byte {length} does " 44 | f"not correspond to length of data {data}" 45 | ) 46 | return tag, length, data 47 | -------------------------------------------------------------------------------- /dlms_cosem/cosem/__init__.py: -------------------------------------------------------------------------------- 1 | from dlms_cosem.cosem.attribute_with_selection import CosemAttributeWithSelection 2 | from dlms_cosem.cosem.base import CosemAttribute, CosemMethod 3 | from dlms_cosem.cosem.obis import Obis 4 | 5 | __all__ = ["CosemAttribute", "CosemMethod", "Obis", "CosemAttributeWithSelection"] 6 | -------------------------------------------------------------------------------- /dlms_cosem/cosem/association.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import cosem, enumerations 7 | 8 | 9 | class AccessRight(IntEnum): 10 | READ_ACCESS = 0 11 | WRITE_ACCESS = 1 12 | AUTHENTICATED_REQUEST = 2 13 | ENCRYPTED_REQUEST = 3 14 | DIGITALLY_SIGNED_REQUEST = 4 15 | AUTHENTICATED_RESPONSE = 5 16 | ENCRYPTED_RESPONSE = 6 17 | DIGITALLY_SIGNED_RESPONSE = 7 18 | 19 | 20 | @attr.s(auto_attribs=True) 21 | class AttributeAccessRights: 22 | attribute: int 23 | access_rights: List[AccessRight] 24 | access_selectors: List[int] = attr.ib( 25 | factory=list, converter=attr.converters.default_if_none(factory=list) 26 | ) 27 | 28 | 29 | @attr.s(auto_attribs=True) 30 | class MethodAccessRights: 31 | method: int 32 | access_rights: List[AccessRight] 33 | 34 | 35 | @attr.s(auto_attribs=True) 36 | class AssociationObjectListItem: 37 | interface: enumerations.CosemInterface 38 | logical_name: cosem.Obis 39 | version: int 40 | attribute_access_rights: Dict[int, AttributeAccessRights] 41 | method_access_rights: Dict[int, MethodAccessRights] 42 | -------------------------------------------------------------------------------- /dlms_cosem/cosem/attribute_with_selection.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | import attr 4 | 5 | from dlms_cosem.cosem import selective_access 6 | 7 | from .base import CosemAttribute 8 | 9 | 10 | @attr.s(auto_attribs=True) 11 | class CosemAttributeWithSelection: 12 | attribute: CosemAttribute 13 | access_selection: Optional[ 14 | Union[selective_access.RangeDescriptor, selective_access.EntryDescriptor] 15 | ] 16 | 17 | @classmethod 18 | def from_bytes(cls, source_bytes: bytes) -> "CosemAttributeWithSelection": 19 | cosem_attribute_data = source_bytes[:9] 20 | cosem_attribute = CosemAttribute.from_bytes(cosem_attribute_data) 21 | data = bytearray(source_bytes[9:]) 22 | has_access_selection = bool(data.pop(0)) 23 | if has_access_selection: 24 | access_selection = selective_access.AccessDescriptorFactory.from_bytes(data) 25 | else: 26 | access_selection = None 27 | 28 | return cls(cosem_attribute, access_selection) 29 | 30 | def to_bytes(self) -> bytes: 31 | out = bytearray() 32 | out.extend(self.attribute.to_bytes()) 33 | if self.access_selection: 34 | out.append(1) 35 | out.extend(self.access_selection.to_bytes()) 36 | else: 37 | out.append(0) 38 | 39 | return out 40 | -------------------------------------------------------------------------------- /dlms_cosem/cosem/base.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | import attr 4 | 5 | from dlms_cosem import enumerations 6 | from dlms_cosem.cosem.obis import Obis 7 | 8 | 9 | @attr.s(auto_attribs=True) 10 | class CosemAttribute: 11 | 12 | interface: enumerations.CosemInterface 13 | instance: Obis 14 | attribute: int 15 | 16 | LENGTH: ClassVar[int] = 2 + 6 + 1 17 | 18 | @classmethod 19 | def from_bytes(cls, source_bytes: bytes): 20 | if len(source_bytes) != cls.LENGTH: 21 | raise ValueError( 22 | f"Data is not of correct lenght. Should be {cls.LENGTH} but is " 23 | f"{len(source_bytes)}" 24 | ) 25 | interface = enumerations.CosemInterface(int.from_bytes(source_bytes[:2], "big")) 26 | instance = Obis.from_bytes(source_bytes[2:8]) 27 | attribute = source_bytes[-1] 28 | return cls(interface, instance, attribute) 29 | 30 | def to_bytes(self) -> bytes: 31 | return b"".join( 32 | [ 33 | self.interface.to_bytes(2, "big"), 34 | self.instance.to_bytes(), 35 | self.attribute.to_bytes(1, "big"), 36 | ] 37 | ) 38 | 39 | 40 | @attr.s(auto_attribs=True) 41 | class CosemMethod: 42 | 43 | interface: enumerations.CosemInterface 44 | instance: Obis 45 | method: int 46 | 47 | LENGTH: ClassVar[int] = 2 + 6 + 1 48 | 49 | @classmethod 50 | def from_bytes(cls, source_bytes: bytes): 51 | if len(source_bytes) != cls.LENGTH: 52 | raise ValueError( 53 | f"Data is not of correct length. Should be {cls.LENGTH} but is " 54 | f"{len(source_bytes)}" 55 | ) 56 | interface = enumerations.CosemInterface(int.from_bytes(source_bytes[:2], "big")) 57 | instance = Obis.from_bytes(source_bytes[2:8]) 58 | method = source_bytes[-1] 59 | return cls(interface, instance, method) 60 | 61 | def to_bytes(self) -> bytes: 62 | return b"".join( 63 | [ 64 | self.interface.to_bytes(2, "big"), 65 | self.instance.to_bytes(), 66 | self.method.to_bytes(1, "big"), 67 | ] 68 | ) 69 | -------------------------------------------------------------------------------- /dlms_cosem/cosem/capture_object.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from dlms_cosem import dlms_data, utils 4 | 5 | from .base import CosemAttribute 6 | 7 | 8 | @attr.s(auto_attribs=True) 9 | class CaptureObject: 10 | """ 11 | Definition of a value that is supposed to be saved in a Profile Generic. 12 | 13 | A data_index of 0 means the whole attribute is referenced. Otherwise it points to a 14 | specific element of the attribute. For example and entry in a buffer. 15 | """ 16 | 17 | cosem_attribute: CosemAttribute 18 | data_index: int = attr.ib(default=0) 19 | 20 | @classmethod 21 | def from_bytes(cls, source_bytes) -> "CaptureObject": 22 | """ 23 | It should be a structure of 4 elements- 24 | """ 25 | # data = utils.parse_as_dlms_data(source_bytes) 26 | raise NotImplementedError() 27 | 28 | def to_bytes(self) -> bytes: 29 | out = bytearray() 30 | out.extend(b"\x02\x04") # A structure of 4 elements 31 | out.extend( 32 | dlms_data.UnsignedLongData(self.cosem_attribute.interface.value).to_bytes() 33 | ) 34 | out.extend( 35 | dlms_data.OctetStringData( 36 | self.cosem_attribute.instance.to_bytes() 37 | ).to_bytes() 38 | ) 39 | out.extend(dlms_data.IntegerData(self.cosem_attribute.attribute).to_bytes()) 40 | out.extend(dlms_data.UnsignedLongData(self.data_index).to_bytes()) 41 | return bytes(out) 42 | -------------------------------------------------------------------------------- /dlms_cosem/cosem/obis.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import * 3 | 4 | import attr 5 | 6 | six_part = re.compile( 7 | "^(\\d{1,3}).(\\d{1,3}).(\\d{1,3}).(\\d{1,3}).(\\d{1,3}).(\\d{1,3})$" 8 | ) 9 | five_part = re.compile("^(\\d{1,3}).(\\d{1,3}).(\\d{1,3}).(\\d{1,3}).(\\d{1,3})$") 10 | 11 | 12 | def allowed_range_for_obis_code(instance, attribute, value: int): 13 | 14 | if 0 > value > 255: 15 | raise ValueError("An obis can only be between 0 - 255") 16 | 17 | 18 | @attr.s(auto_attribs=True) 19 | class Obis: 20 | 21 | """ 22 | OBject Identification System defines codes for identification of commonly used 23 | data items in metering equipment. 24 | """ 25 | 26 | a: int = attr.ib( 27 | validator=[attr.validators.instance_of(int), allowed_range_for_obis_code] 28 | ) 29 | b: int = attr.ib( 30 | validator=[attr.validators.instance_of(int), allowed_range_for_obis_code] 31 | ) 32 | c: int = attr.ib( 33 | validator=[attr.validators.instance_of(int), allowed_range_for_obis_code] 34 | ) 35 | d: int = attr.ib( 36 | validator=[attr.validators.instance_of(int), allowed_range_for_obis_code] 37 | ) 38 | e: int = attr.ib( 39 | validator=[attr.validators.instance_of(int), allowed_range_for_obis_code] 40 | ) 41 | f: int = attr.ib( 42 | validator=[attr.validators.instance_of(int), allowed_range_for_obis_code], 43 | default=255, 44 | ) 45 | 46 | @classmethod 47 | def from_bytes(cls, source_bytes: bytes): 48 | data = bytearray(source_bytes) 49 | if len(data) != 6: 50 | raise ValueError( 51 | f"Not enough data to parse OBIS. Need 6 bytes but got {len(data)}" 52 | ) 53 | return cls(data[0], data[1], data[2], data[3], data[4], data[5]) 54 | 55 | @classmethod 56 | def from_string(cls, obis_string: str) -> "Obis": 57 | """ 58 | Parses a string as an OBIS code. Will accept with both the optinal 255 at the 59 | and and not. Any separator is allowed. 60 | """ 61 | six_match = re.match(six_part, obis_string) 62 | if six_match: 63 | parts = six_match.groups() 64 | return cls( 65 | a=int(parts[0]), 66 | b=int(parts[1]), 67 | c=int(parts[2]), 68 | d=int(parts[3]), 69 | e=int(parts[4]), 70 | ) 71 | five_match = re.match(five_part, obis_string) 72 | if five_match: 73 | parts = five_match.groups() 74 | return cls( 75 | a=int(parts[0]), 76 | b=int(parts[1]), 77 | c=int(parts[2]), 78 | d=int(parts[3]), 79 | e=int(parts[4]), 80 | ) 81 | 82 | raise ValueError(f"{obis_string} is not a parsable OBIS string") 83 | 84 | def to_string(self, separator: Optional[str] = None) -> str: 85 | if separator: 86 | return ( 87 | f"{self.a}{separator}{self.b}{separator}{self.c}{separator}{self.d}" 88 | f"{separator}{self.e}{separator}{self.f}" 89 | ) 90 | else: 91 | return f"{self.a}-{self.b}:{self.c}.{self.d}.{self.e}.{self.f}" 92 | 93 | def to_bytes(self) -> bytes: 94 | return bytes(bytearray([self.a, self.b, self.c, self.d, self.e, self.f])) 95 | -------------------------------------------------------------------------------- /dlms_cosem/cosem/profile_generic.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import cosem 7 | from dlms_cosem import enumerations as enums 8 | from dlms_cosem import parsers 9 | from dlms_cosem.cosem.selective_access import CaptureObject, RangeDescriptor 10 | 11 | 12 | class SortMethod(IntEnum): 13 | FIFO = 1 14 | LIFO = 2 15 | LARGEST = 3 16 | SMALLEST = 4 17 | NEAREST_TO_ZERO = 5 18 | FARTHEST_FROM_ZERO = 6 19 | 20 | 21 | @attr.s(auto_attribs=True) 22 | class AttributeDescription: 23 | attribute_id: int 24 | attribute_name: str 25 | data_parser: Optional[Any] = attr.ib(default=None) 26 | data_converter: Optional[Any] = attr.ib(default=None) # callable? 27 | 28 | 29 | @attr.s(auto_attribs=True) 30 | class Data: 31 | INTERFACE_CLASS_ID: ClassVar[enums.CosemInterface] = enums.CosemInterface.DATA 32 | 33 | logical_name: cosem.Obis 34 | value: Any 35 | 36 | STATIC_ATTRIBUTES: ClassVar[Dict[int, AttributeDescription]] = { 37 | 1: AttributeDescription(attribute_id=1, attribute_name="logical_name"), 38 | } 39 | 40 | DYNAMIC_ATTRIBUTES: ClassVar[Dict[int, AttributeDescription]] = { 41 | 2: AttributeDescription(attribute_id=2, attribute_name="value"), 42 | } 43 | 44 | SELECTIVE_ACCESS: ClassVar[Dict[int, Type[RangeDescriptor]]] = {} 45 | 46 | METHODS: ClassVar[Dict[int, str]] = {1: "reset", 2: "capture"} 47 | 48 | DYNAMIC_CONVERTERS: ClassVar[Dict[int, Callable]] = {} 49 | 50 | def is_static_attribute(self, attribute_id: int) -> bool: 51 | return attribute_id in self.STATIC_ATTRIBUTES.keys() 52 | 53 | 54 | def convert_load_profile(instance, data): 55 | parser = parsers.ProfileGenericBufferParser( 56 | capture_objects=[x.cosem_attribute for x in instance.capture_objects], 57 | capture_period=instance.capture_period, 58 | ) 59 | return parser.parse_entries(data) 60 | 61 | 62 | @attr.s(auto_attribs=True) 63 | class ProfileGeneric: 64 | INTERFACE_CLASS_ID: ClassVar[ 65 | enums.CosemInterface 66 | ] = enums.CosemInterface.PROFILE_GENERIC 67 | 68 | logical_name: cosem.Obis 69 | buffer = List[List[Any]] 70 | capture_objects: List[CaptureObject] 71 | capture_period: int 72 | sort_method: Optional[SortMethod] = attr.ib(default=None) 73 | sort_object: Optional[CaptureObject] = attr.ib(default=None) 74 | entries_in_use: Optional[int] = attr.ib(default=None) 75 | profile_entries: Optional[int] = attr.ib(default=None) 76 | 77 | STATIC_ATTRIBUTES: ClassVar[Dict[int, AttributeDescription]] = { 78 | 1: AttributeDescription(attribute_id=1, attribute_name="logical_name"), 79 | 3: AttributeDescription(attribute_id=3, attribute_name="capture_objects"), 80 | 4: AttributeDescription(attribute_id=4, attribute_name="capture_period"), 81 | 5: AttributeDescription(attribute_id=5, attribute_name="sort_method"), 82 | 6: AttributeDescription(attribute_id=6, attribute_name="sort_object"), 83 | 8: AttributeDescription(attribute_id=8, attribute_name="profile_entries"), 84 | } 85 | 86 | DYNAMIC_ATTRIBUTES: ClassVar[Dict[int, AttributeDescription]] = { 87 | 2: AttributeDescription(attribute_id=2, attribute_name="buffer"), 88 | 7: AttributeDescription(attribute_id=7, attribute_name="entries_in_use"), 89 | } 90 | 91 | SELECTIVE_ACCESS: ClassVar[Dict[int, Type[RangeDescriptor]]] = {2: RangeDescriptor} 92 | 93 | METHODS: ClassVar[Dict[int, str]] = {1: "reset", 2: "capture"} 94 | 95 | # Todo: needs to take instance of the class 96 | DYNAMIC_CONVERTERS: ClassVar[Dict[int, Callable]] = {2: convert_load_profile} 97 | 98 | def reset(self, data: int = 0): 99 | """clears the buffer""" 100 | ... 101 | 102 | def capture(self, data: int = 0): 103 | """initiate a capture""" 104 | 105 | def is_static_attribute(self, attribute_id: int) -> bool: 106 | return attribute_id in self.STATIC_ATTRIBUTES.keys() 107 | -------------------------------------------------------------------------------- /dlms_cosem/cosem/selective_access.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import cosem, dlms_data, enumerations, time, utils 7 | from dlms_cosem.cosem.capture_object import CaptureObject 8 | 9 | 10 | @attr.s(auto_attribs=True) 11 | class RangeDescriptor: 12 | """ 13 | The range descriptor can be used to read buffers of Profile Generic. 14 | Only buffer element that corresponds to the descriptor shall be returned in a get 15 | request. 16 | 17 | 18 | """ 19 | 20 | ACCESS_DESCRIPTOR: ClassVar[int] = 1 21 | 22 | restricting_object: CaptureObject = attr.ib( 23 | validator=attr.validators.instance_of(CaptureObject) 24 | ) 25 | from_value: datetime = attr.ib(validator=attr.validators.instance_of(datetime)) 26 | to_value: datetime = attr.ib(validator=attr.validators.instance_of(datetime)) 27 | selected_values: Optional[List[CaptureObject]] = attr.ib(default=None) 28 | 29 | @classmethod 30 | def from_bytes(cls, source_bytes: bytes) -> "RangeDescriptor": 31 | data = bytearray(source_bytes) 32 | access_descriptor = data.pop(0) 33 | if access_descriptor is not cls.ACCESS_DESCRIPTOR: 34 | raise ValueError( 35 | f"Access descriptor {access_descriptor} is not valid for " 36 | f"RangeDescriptor. It should be {cls.ACCESS_DESCRIPTOR}" 37 | ) 38 | parsed_data = utils.parse_as_dlms_data(data) 39 | 40 | restricting_object_data = parsed_data[0] 41 | from_value_data = parsed_data[1] 42 | to_value_data = parsed_data[2] 43 | selected_values_data = parsed_data[3] 44 | 45 | restricting_cosem_attribute = cosem.CosemAttribute( 46 | interface=enumerations.CosemInterface(restricting_object_data[0]), 47 | instance=cosem.Obis.from_bytes(restricting_object_data[1]), 48 | attribute=restricting_object_data[2], 49 | ) 50 | restricting_object = CaptureObject( 51 | cosem_attribute=restricting_cosem_attribute, 52 | data_index=restricting_object_data[3], 53 | ) 54 | from_dt, clock_status = time.datetime_from_bytes(from_value_data) 55 | to_dt, clock_status = time.datetime_from_bytes(to_value_data) 56 | if selected_values_data: 57 | raise NotImplementedError() 58 | else: 59 | selected_values = None 60 | 61 | return cls( 62 | restricting_object=restricting_object, 63 | from_value=from_dt, 64 | to_value=to_dt, 65 | selected_values=selected_values, 66 | ) 67 | 68 | def to_bytes(self) -> bytes: 69 | out = bytearray() 70 | out.append(self.ACCESS_DESCRIPTOR) 71 | out.extend(b"\x02\x04") # structure of 4 elements 72 | out.extend(self.restricting_object.to_bytes()) 73 | out.extend( 74 | dlms_data.OctetStringData( 75 | time.datetime_to_bytes(self.from_value) 76 | ).to_bytes() 77 | ) 78 | out.extend( 79 | dlms_data.OctetStringData(time.datetime_to_bytes(self.to_value)).to_bytes() 80 | ) 81 | if not self.selected_values: 82 | out.extend(b"\x01\x00") # empty array for selected values means all columns 83 | else: 84 | raise NotImplementedError() 85 | # TODO: implement selected values 86 | 87 | return bytes(out) 88 | 89 | 90 | def validate_unsigned_double_long_int(instance, attribute, value): 91 | if 0 >= value >= 0xFFFFFFFF: 92 | raise ValueError( 93 | f"{value} is not withing the limits of a unsigned double long integer" 94 | ) 95 | 96 | 97 | def validate_unsigned_long_int(instance, attribute, value): 98 | if 0 >= value >= 0xFFFF: 99 | raise ValueError( 100 | f"{value} is not withing the limits of a unsigned long integer" 101 | ) 102 | 103 | 104 | @attr.s(auto_attribs=True) 105 | class EntryDescriptor: 106 | """ 107 | The entry descriptor limits response data by entries. 108 | It is possible to limit the entries and also the columns returned. 109 | The from/to_selected_value limits the columns returned from/to_entry limits the 110 | entries. 111 | 112 | Numbering of selected values and entries start from 1. 113 | Setting to_entry=0 or to_selected_value=0 requests the highest possible value. 114 | """ 115 | 116 | ACCESS_DESCRIPTOR: ClassVar[int] = 2 117 | 118 | from_entry: int = attr.ib( 119 | validator=[validate_unsigned_double_long_int, attr.validators.instance_of(int)] 120 | ) 121 | to_entry: int = attr.ib( 122 | validator=[validate_unsigned_double_long_int, attr.validators.instance_of(int)], 123 | default=0, 124 | ) 125 | from_selected_value: int = attr.ib( 126 | validator=[validate_unsigned_long_int, attr.validators.instance_of(int)], 127 | default=1, 128 | ) 129 | to_selected_value: int = attr.ib( 130 | validator=[validate_unsigned_long_int, attr.validators.instance_of(int)], 131 | default=0, 132 | ) 133 | 134 | @classmethod 135 | def from_bytes(cls, source_bytes) -> "EntryDescriptor": 136 | raise NotImplementedError() 137 | 138 | def to_bytes(self) -> bytes: 139 | raise NotImplementedError() 140 | 141 | 142 | @attr.s(auto_attribs=True) 143 | class AccessDescriptorFactory: 144 | 145 | """ 146 | Handles the selection of parsing the first byte to find what kind of access 147 | descriptor it is and returns the object. 148 | """ 149 | 150 | @staticmethod 151 | def from_bytes(source_bytes: bytes) -> Union[RangeDescriptor, EntryDescriptor]: 152 | 153 | access_descriptor = source_bytes[0] 154 | if access_descriptor == 1: 155 | return RangeDescriptor.from_bytes(source_bytes) 156 | elif access_descriptor == 2: 157 | return EntryDescriptor.from_bytes(source_bytes) 158 | else: 159 | raise ValueError(f"{access_descriptor} is not a valid access descriptor") 160 | -------------------------------------------------------------------------------- /dlms_cosem/crc.py: -------------------------------------------------------------------------------- 1 | from ctypes import c_ushort 2 | from typing import * 3 | 4 | 5 | class CRCCCITT: 6 | """ 7 | CRC CCITT - HDLC Style 16-bit 8 | In accordning with ANSI C12.18(2006) 9 | Using 0xFFFF as initial value 10 | Running over serial so all message bytes need to be reversed 11 | before calculation (because least significant bit is sent first) 12 | resulting crc bytes needs to be reversed to become in correct order 13 | The reversed crc is then XOR:ed with 0xFFFF 14 | 15 | """ 16 | 17 | crc_ccitt_table: List[int] = list() 18 | 19 | # The CRC's are computed using polynomials. 20 | 21 | crc_ccitt_constant = 0x1021 22 | 23 | def __init__(self): 24 | self.starting_value = 0xFFFF 25 | 26 | # initialize the pre-calculated tables 27 | if not len(self.crc_ccitt_table): 28 | self.init_crc_table() 29 | 30 | def calculate_for(self, input_data, lsb_first=False) -> bytes: 31 | """ 32 | 33 | :param input_data: 34 | :param lsb_first: Indicate if the Least significant byte should be returned 35 | first (little endian) 36 | :return: 37 | """ 38 | 39 | # need to revers bits in bytes 40 | reversed_data = reverse_byte_message(input_data) 41 | 42 | reversed_crc = self._calculate(reversed_data) 43 | lsb_rev = reversed_crc & 0x00FF 44 | lsb: int = ord(reverse_byte(lsb_rev)) 45 | lsb ^= 0xFF 46 | lsb_byte = lsb.to_bytes(1, "big") 47 | msb_rev = (reversed_crc & 0xFF00) >> 8 48 | msb = ord(reverse_byte(msb_rev)) 49 | msb ^= 0xFF 50 | msb_byte = msb.to_bytes(1, "big") 51 | 52 | if lsb_first: 53 | return b"".join([lsb_byte, msb_byte]) 54 | else: 55 | return b"".join([msb_byte, lsb_byte]) 56 | 57 | def _calculate(self, input_data: bytes): 58 | 59 | crc_value = self.starting_value 60 | 61 | for c in input_data: 62 | tmp = ((crc_value >> 8) & 0xFF) ^ c 63 | crc_shifted = (crc_value << 8) & 0xFF00 64 | crc_value = crc_shifted ^ self.crc_ccitt_table[tmp] 65 | 66 | return crc_value 67 | 68 | def init_crc_table(self): 69 | """The algorithm uses tables with pre-calculated values""" 70 | for i in range(0, 256): 71 | crc = 0 72 | c = i << 8 73 | 74 | for j in range(0, 8): 75 | if (crc ^ c) & 0x8000: 76 | crc = c_ushort(crc << 1).value ^ self.crc_ccitt_constant 77 | else: 78 | crc = c_ushort(crc << 1).value 79 | 80 | c = c_ushort(c << 1).value # equivalent of c = c << 1 81 | 82 | self.crc_ccitt_table.append(crc) 83 | 84 | 85 | def reverse_byte(byte_to_reverse): 86 | and_value = 1 87 | reversed_byte = 0 88 | for i in range(0, 8): 89 | reversed_byte += ((byte_to_reverse & and_value) >> i) * (2 ** (7 - i)) 90 | and_value += and_value 91 | 92 | return (chr(reversed_byte)).encode("latin-1") 93 | 94 | 95 | def reverse_byte_message(msg): 96 | reversed_mgs = b"" 97 | for char in msg: 98 | reversed_mgs += reverse_byte(char) 99 | return reversed_mgs 100 | -------------------------------------------------------------------------------- /dlms_cosem/exceptions.py: -------------------------------------------------------------------------------- 1 | class LocalDlmsProtocolError(Exception): 2 | """Protocol error""" 3 | 4 | 5 | class ApplicationAssociationError(Exception): 6 | """Something went wrong when trying to setup the application association""" 7 | 8 | 9 | class PreEstablishedAssociationError(Exception): 10 | """An error when doing illegal things to the connection if it pre established""" 11 | 12 | 13 | class ConformanceError(Exception): 14 | """If APDUs does not match connection Conformance""" 15 | 16 | 17 | class CipheringError(Exception): 18 | """Something went wrong when ciphering or deciphering an APDU""" 19 | 20 | 21 | class DlmsClientException(Exception): 22 | """An exception that is relating to the client""" 23 | 24 | 25 | class CommunicationError(Exception): 26 | """Something went wrong in the communication with a meter""" 27 | 28 | 29 | class CryptographyError(Exception): 30 | """Something went wrong then applying a cryptographic function""" 31 | 32 | 33 | class DecryptionError(CryptographyError): 34 | """ 35 | Unable to decrypt an APDU. It can be due to mismatch in authentication tag 36 | because the ciphertext has changed or that the key, nonce or associated data is 37 | wrong 38 | """ 39 | 40 | 41 | class NoRlrqRlreError(Exception): 42 | """ 43 | Is raised from connection when a ReleaseRequest is issued on a connection that has use_rlrq_rlre==False 44 | Control for client to just skip Release and disconnect the lower layer. 45 | """ -------------------------------------------------------------------------------- /dlms_cosem/hdlc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/dlms_cosem/hdlc/__init__.py -------------------------------------------------------------------------------- /dlms_cosem/hdlc/connection.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import structlog 3 | 4 | from dlms_cosem.hdlc import address, exceptions, frames 5 | from dlms_cosem.hdlc.exceptions import LocalProtocolError 6 | from dlms_cosem.hdlc.state import ( 7 | AWAITING_CONNECTION, 8 | AWAITING_DISCONNECT, 9 | AWAITING_RESPONSE, 10 | NEED_DATA, 11 | HdlcConnectionState, 12 | ) 13 | 14 | LOG = structlog.get_logger() 15 | 16 | 17 | @attr.s(auto_attribs=True) 18 | class HdlcConnection: 19 | """ 20 | HDLC - High-level Data Link Control 21 | 22 | In DLMS/COSEM HDLC is used to send DLMS data over serial interfaces. Like the optical 23 | probe for diagnostic. Some meters also send data over TCP sockets sing HDLC when they 24 | have not implemented the TCP transport variant of DLMS/COSEM. 25 | 26 | Tracks the state of HDLC communication and transforming bytes to frames and 27 | frames to bytes. 28 | """ 29 | 30 | client_address: address.HdlcAddress 31 | server_address: address.HdlcAddress 32 | client_ssn: int = attr.ib(init=False, default=0) 33 | client_rsn: int = attr.ib(init=False, default=0) 34 | server_ssn: int = attr.ib(init=False, default=0) 35 | server_rsn: int = attr.ib(init=False, default=0) 36 | max_data_size: int = attr.ib(default=128) 37 | state: HdlcConnectionState = attr.ib(factory=HdlcConnectionState) 38 | buffer: bytearray = attr.ib(factory=bytearray) 39 | buffer_search_position: int = 1 40 | 41 | def send(self, frame) -> bytes: 42 | """ 43 | Returns the bytes to be sent over I/O for a frame and changes the connection 44 | state depending on frame. 45 | :param frame: HDLC frame: 46 | :return: bytes 47 | """ 48 | self.state.process_frame(frame) 49 | 50 | if isinstance(frame, frames.InformationFrame): 51 | self.handle_sequence_numbers( 52 | frame_ssn=frame.send_sequence_number, 53 | frame_rsn=frame.receive_sequence_number, 54 | response=False, 55 | ) 56 | 57 | return frame.to_bytes() 58 | 59 | def handle_sequence_numbers(self, frame_ssn: int, frame_rsn: int, response: bool): 60 | if not response: 61 | if frame_ssn != self.server_ssn or frame_rsn != self.server_rsn: 62 | raise LocalProtocolError( 63 | f"Frame sequence numbers are wrong: frame(ssn: {frame_ssn}, rsn: " 64 | f"{frame_rsn}) =! client(ssn:{self.server_ssn}, " 65 | f"rsn:{self.server_rsn})" 66 | ) 67 | self.server_ssn += 1 68 | self.client_rsn += 1 69 | else: 70 | if frame_ssn != self.client_ssn or frame_rsn != self.client_rsn: 71 | raise LocalProtocolError( 72 | f"Frame sequence numbers are wrong: frame(ssn: {frame_ssn}, rsn: " 73 | f"{frame_rsn}) =! client(ssn:{self.server_ssn}, " 74 | f"rsn:{self.server_rsn})" 75 | ) 76 | self.server_rsn += 1 77 | self.client_ssn += 1 78 | 79 | if self.server_rsn > 7: 80 | self.server_rsn = 0 81 | if self.server_ssn > 7: 82 | self.server_ssn = 0 83 | if self.client_rsn > 7: 84 | self.client_rsn = 0 85 | if self.client_ssn > 7: 86 | self.client_ssn = 0 87 | 88 | def receive_data(self, data: bytes): 89 | """ 90 | Add data into the receive buffer. 91 | After this you could call next_event 92 | """ 93 | if data: 94 | LOG.debug(f"Added data to buffer", data=data) 95 | self.buffer += data 96 | 97 | def next_event(self): 98 | """ 99 | Will try to parse a frame from the buffer. If a frame is found the buffer is 100 | cleared of the bytes making up the frame and the frame is returned. 101 | If the frame is not parsable we assume it is not compleate and we return a 102 | NEED_DATA event to signal we need to receive more data. 103 | :return: 104 | """ 105 | frame_bytes = self._find_frame() 106 | if frame_bytes is None: 107 | return NEED_DATA 108 | 109 | if ( 110 | self.state.current_state == AWAITING_CONNECTION 111 | or self.state.current_state == AWAITING_DISCONNECT 112 | ): 113 | 114 | try: 115 | frame = frames.UnNumberedAcknowledgmentFrame.from_bytes(frame_bytes) 116 | except (exceptions.HdlcParsingError, ValueError): 117 | frame = None 118 | 119 | elif self.state.current_state == AWAITING_RESPONSE: 120 | # It can be a InformationFrame or a ReceiveReadyFrame in case we have sent 121 | # a segmented frame. 122 | try: 123 | frame = frames.InformationFrame.from_bytes(frame_bytes) 124 | except (exceptions.HdlcParsingError, ValueError): 125 | # Not an information frame. Should be a receive ready frame 126 | frame = None 127 | 128 | if frame is None: 129 | try: 130 | frame = frames.ReceiveReadyFrame.from_bytes(frame_bytes) 131 | except (exceptions.HdlcParsingError, ValueError): 132 | frame = None 133 | else: 134 | frame = None 135 | 136 | if frame is None: 137 | LOG.debug("HDLC frame could not be parsed. Need more data") 138 | return NEED_DATA 139 | 140 | LOG.debug(f"Received HDLC frame", frame=frame) 141 | self.state.process_frame(frame) 142 | self._tidy_buffer() 143 | 144 | if isinstance(frame, frames.InformationFrame): 145 | self.handle_sequence_numbers( 146 | frame_ssn=frame.send_sequence_number, 147 | frame_rsn=frame.receive_sequence_number, 148 | response=True, 149 | ) 150 | 151 | return frame 152 | 153 | def _find_frame(self): 154 | """ 155 | To find a frame in the buffer we need to assume somethings. 156 | 1. The first character in the buffer should be the HDLC_FLAG. 157 | During normal operations we will have frames with flags on both ends. 158 | But with windowing one might be omitted in long information frame exchanges 159 | Ex: 7e{frame}7e{frame}7e. The second one would not have an initial 7e after 160 | we take out the first frame. 161 | So if the intial byte is not 7e we should manually add it. 162 | 163 | 2. We might find an incomplete frame if the second 7e was found as data and not 164 | actually an end flag. So we need to keep the current end memory so we can 165 | extend the search if we cant parse the frame. 166 | 167 | 3. Once we have parsed a proper frame we shoudl call clear buffer that will 168 | remove the data for the frame from the buffer. 169 | :return: 170 | """ 171 | try: 172 | frame_end = ( 173 | self.buffer.index(frames.HDLC_FLAG, self.buffer_search_position) + 1 174 | ) 175 | except ValueError: 176 | # .index raises ValueError on not finding subsection 177 | return None 178 | 179 | frame_bytes = self.buffer[:frame_end] 180 | self.buffer_search_position = frame_end 181 | 182 | if not frame_bytes.startswith(frames.HDLC_FLAG): 183 | frame_bytes.insert(0, ord(frames.HDLC_FLAG)) 184 | 185 | return frame_bytes 186 | 187 | def _tidy_buffer(self): 188 | """ 189 | Remove the bytes we have extracted. 190 | """ 191 | del self.buffer[: self.buffer_search_position] 192 | self.buffer_search_position = 1 193 | -------------------------------------------------------------------------------- /dlms_cosem/hdlc/exceptions.py: -------------------------------------------------------------------------------- 1 | class LocalProtocolError(Exception): 2 | """Error in HDLC Protocol""" 3 | 4 | 5 | class HdlcException(Exception): 6 | """Base class for HDLC protocol parts""" 7 | 8 | 9 | class HdlcParsingError(HdlcException): 10 | """An error occurred then parsing bytes into HDLC object""" 11 | 12 | 13 | class MissingHdlcFlags(HdlcParsingError): 14 | """Frame is not enclosed byt HDLC flags""" 15 | -------------------------------------------------------------------------------- /dlms_cosem/hdlc/state.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import structlog 3 | 4 | from dlms_cosem.hdlc import frames 5 | from dlms_cosem.hdlc.exceptions import LocalProtocolError 6 | 7 | LOG = structlog.get_logger() 8 | 9 | 10 | class _SentinelBase(type): 11 | """ 12 | Sentinel values 13 | 14 | - Inherit identity-based comparison and hashing from object 15 | - Have a nice repr 16 | - Have a *bonus property*: type(sentinel) is sentinel 17 | 18 | The bonus property is useful if you want to take the return value from 19 | next_event() and do some sort of dispatch based on type(event). 20 | 21 | Taken from h11. 22 | """ 23 | 24 | def __repr__(self): 25 | return self.__name__ 26 | 27 | 28 | def make_sentinel(name): 29 | cls = _SentinelBase(name, (_SentinelBase,), {}) 30 | cls.__class__ = cls 31 | return cls 32 | 33 | 34 | # NOT_CONNECTED is when we have created a session but not actually set up HDLC 35 | # connection with the server (meter). We used a SNMR frame to set up the connection 36 | NOT_CONNECTED = make_sentinel("NOT_CONNECTED") 37 | 38 | # IDLE State is when we are connected but we have not started a data exchange or we 39 | # just finished a data exchange 40 | IDLE = make_sentinel("IDLE") 41 | 42 | AWAITING_RESPONSE = make_sentinel("AWAITING_RESPONSE") 43 | 44 | 45 | AWAITING_CONNECTION = make_sentinel("AWAITING_CONNECTION") 46 | 47 | AWAITING_DISCONNECT = make_sentinel("AWAITING_DISCONNECT") 48 | 49 | CLOSED = make_sentinel("CLOSED") 50 | 51 | NEED_DATA = make_sentinel("NEED_DATA") 52 | 53 | # TODO: segmentation handling is not working with this state layout. 54 | 55 | HDLC_STATE_TRANSITIONS = { 56 | NOT_CONNECTED: {frames.SetNormalResponseModeFrame: AWAITING_CONNECTION}, 57 | AWAITING_CONNECTION: {frames.UnNumberedAcknowledgmentFrame: IDLE}, 58 | IDLE: { 59 | frames.InformationFrame: AWAITING_RESPONSE, 60 | frames.DisconnectFrame: AWAITING_DISCONNECT, 61 | frames.ReceiveReadyFrame: AWAITING_RESPONSE, 62 | }, 63 | AWAITING_RESPONSE: {frames.InformationFrame: IDLE, frames.ReceiveReadyFrame: IDLE}, 64 | AWAITING_DISCONNECT: {frames.UnNumberedAcknowledgmentFrame: NOT_CONNECTED}, 65 | } 66 | 67 | 68 | SEND_STATES = [NOT_CONNECTED, IDLE] 69 | RECEIVE_STATES = [AWAITING_CONNECTION, AWAITING_RESPONSE, AWAITING_DISCONNECT] 70 | 71 | # TODO: does the ssn and rsn belong in the state? Comparing to H11 that is only 72 | # using types in the state not full objects. Maybe it should be stored on the 73 | # connection? 74 | 75 | 76 | @attr.s(auto_attribs=True) 77 | class HdlcConnectionState: 78 | """ 79 | Handles state changes in HDLC, we only focus on Client implementation as of now. 80 | 81 | A HDLC frame is passed to `process_frame` and it moves the state machine to the 82 | correct state. If a frame is processed that is not set to be able to transition 83 | the state in the current state a LocalProtocolError is raised. 84 | """ 85 | 86 | current_state: _SentinelBase = attr.ib(default=NOT_CONNECTED) 87 | 88 | def process_frame(self, frame): 89 | 90 | self._transition_state(type(frame)) 91 | 92 | def _transition_state(self, frame_type): 93 | try: 94 | new_state = HDLC_STATE_TRANSITIONS[self.current_state][frame_type] 95 | except KeyError: 96 | raise LocalProtocolError( 97 | f"can't handle frame type {frame_type} when state={self.current_state}" 98 | ) 99 | old_state = self.current_state 100 | self.current_state = new_state 101 | LOG.debug(f"HDLC state transitioned", old_state=old_state, new_state=new_state) 102 | -------------------------------------------------------------------------------- /dlms_cosem/hdlc/validators.py: -------------------------------------------------------------------------------- 1 | def validate_information_sequence_number(instance, attribute, value): 2 | if not 0 <= value <= 7: 3 | raise ValueError(f"Sequence number can only be between 0-7. Got {value}") 4 | 5 | 6 | def validate_hdlc_address_type(instance, attribute, value): 7 | if value not in ["client", "server"]: 8 | raise ValueError("HdlcAddress type can only be client or server.") 9 | 10 | 11 | def validate_hdlc_address(instance, attribute, value): 12 | """ 13 | Client addresses should always be expressed in 1 byte. 14 | With the marking bit that leaves 7 bits for address. 15 | 16 | A server address can be expressed in 1 or 2 bytes (well technically 2 or 4 but that 17 | is including both the logical and physical address. Each value is limited to max 2 bytes 18 | but 7 bits in each byte. 19 | 20 | 21 | """ 22 | if (attribute.name == "physical_address") & (value is None): 23 | # we allow physical address to be none. 24 | return 25 | 26 | if instance.address_type == "client": 27 | address_limit = 0b01111111 28 | 29 | else: # server 30 | address_limit = 0b0011111111111111 31 | 32 | if value > address_limit: 33 | raise ValueError( 34 | f"Hdlc {instance.address_type} address cannot be higher " 35 | f"than {address_limit}, but is {value}" 36 | ) 37 | 38 | if value < 0: 39 | raise ValueError("Hdlc address cannot have a negative value.") 40 | -------------------------------------------------------------------------------- /dlms_cosem/parsers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import a_xdr, cosem, enumerations 7 | from dlms_cosem.cosem import CosemAttribute 8 | from dlms_cosem.cosem.association import ( 9 | AccessRight, 10 | AssociationObjectListItem, 11 | AttributeAccessRights, 12 | MethodAccessRights, 13 | ) 14 | from dlms_cosem.time import datetime_from_bytes 15 | 16 | 17 | @attr.s(auto_attribs=True) 18 | class ColumnValue: 19 | 20 | attribute: CosemAttribute 21 | value: Any 22 | 23 | 24 | @attr.s(auto_attribs=True) 25 | class ProfileGenericBufferParser: 26 | 27 | capture_objects: List[CosemAttribute] 28 | capture_period: int # minutes 29 | 30 | def parse_bytes(self, profile_bytes: bytes): 31 | """ 32 | Profile generic are sent as a sequence of A-XDR encoded DlmsData. 33 | """ 34 | data_decoder = a_xdr.AXdrDecoder( 35 | encoding_conf=a_xdr.EncodingConf( 36 | attributes=[a_xdr.Sequence(attribute_name="data")] 37 | ) 38 | ) 39 | entries: List[List[Any]] = data_decoder.decode(profile_bytes)["data"] 40 | 41 | return self.parse_entries(entries) 42 | 43 | def parse_entries( 44 | self, entries: List[List[Optional[Any]]] 45 | ) -> List[List[Optional[ColumnValue]]]: 46 | """ 47 | Returns a list of columns with the cosem attribut linked to the value with a 48 | ColumnValue. 49 | It also sets the timestamp on each column calculated from the prevoius entry 50 | if the data has been sent compressed using null values 51 | """ 52 | parsed_entries = list() 53 | last_entry_timestamp: Optional[datetime] = None 54 | for entry in entries: 55 | if len(entry) != len(self.capture_objects): 56 | raise ValueError( 57 | f"Unable to parse ProfileGeneric entry as the amount of columns " 58 | f"({len(entry)}) differ from the parsers set capture_object length " 59 | f"({len(self.capture_objects)}) " 60 | ) 61 | parsed_column = list() 62 | for index, column in enumerate(entry): 63 | cosem_attribute = self.capture_objects[index] 64 | if column is not None: 65 | if cosem_attribute.interface == enumerations.CosemInterface.CLOCK: 66 | # parse as time. 67 | value = datetime_from_bytes(column)[ 68 | 0 69 | ] # TODO: do we need clock status? 70 | last_entry_timestamp = value 71 | parsed_column.append( 72 | ColumnValue(attribute=cosem_attribute, value=value) 73 | ) 74 | else: 75 | parsed_column.append( 76 | ColumnValue(attribute=cosem_attribute, value=column) 77 | ) 78 | else: 79 | if cosem_attribute.interface == enumerations.CosemInterface.CLOCK: 80 | if last_entry_timestamp: 81 | value = last_entry_timestamp + timedelta( 82 | minutes=self.capture_period 83 | ) 84 | last_entry_timestamp = value 85 | parsed_column.append( 86 | ColumnValue(attribute=cosem_attribute, value=value) 87 | ) 88 | 89 | else: 90 | parsed_column.append(None) 91 | 92 | parsed_entries.append(parsed_column) 93 | 94 | return parsed_entries 95 | 96 | 97 | class AssociationObjectListParser: 98 | @staticmethod 99 | def parse_bytes(profile_bytes: bytes): 100 | """ 101 | Profile generic are sent as a sequence of A-XDR encoded DlmsData. 102 | """ 103 | data_decoder = a_xdr.AXdrDecoder( 104 | encoding_conf=a_xdr.EncodingConf( 105 | attributes=[a_xdr.Sequence(attribute_name="data")] 106 | ) 107 | ) 108 | entries: List[List[Any]] = data_decoder.decode(profile_bytes)["data"] 109 | 110 | return AssociationObjectListParser.parse_entries(entries) 111 | 112 | @staticmethod 113 | def parse_access_right(access_right: int) -> List[AccessRight]: 114 | parsed_access_rights = list() 115 | if bool(access_right & 0b00000001): 116 | parsed_access_rights.append(AccessRight.READ_ACCESS.value) 117 | if bool(access_right & 0b00000010): 118 | parsed_access_rights.append(AccessRight.WRITE_ACCESS.value) 119 | if bool(access_right & 0b00000100): 120 | parsed_access_rights.append(AccessRight.AUTHENTICATED_REQUEST.value) 121 | if bool(access_right & 0b00001000): 122 | parsed_access_rights.append(AccessRight.ENCRYPTED_REQUEST.value) 123 | if bool(access_right & 0b00010000): 124 | parsed_access_rights.append(AccessRight.DIGITALLY_SIGNED_REQUEST.value) 125 | if bool(access_right & 0b00100000): 126 | parsed_access_rights.append(AccessRight.AUTHENTICATED_RESPONSE.value) 127 | if bool(access_right & 0b01000000): 128 | parsed_access_rights.append(AccessRight.ENCRYPTED_RESPONSE.value) 129 | if bool(access_right & 0b10000000): 130 | parsed_access_rights.append(AccessRight.DIGITALLY_SIGNED_RESPONSE.value) 131 | 132 | return parsed_access_rights 133 | 134 | @staticmethod 135 | def parse_attribute_access_rights( 136 | access_rights: List[List[Optional[Union[int, List[int]]]]] 137 | ): 138 | parsed_access_rights = list() 139 | for right in access_rights: 140 | parsed_access_rights.append( 141 | AttributeAccessRights( 142 | attribute=right[0], 143 | access_rights=AssociationObjectListParser.parse_access_right( 144 | right[1] 145 | ), 146 | access_selectors=right[2], 147 | ) 148 | ) 149 | return parsed_access_rights 150 | 151 | @staticmethod 152 | def parse_method_access_rights( 153 | access_rights: List[List[int]], 154 | ) -> List[MethodAccessRights]: 155 | parsed_access_rights = list() 156 | for right in access_rights: 157 | parsed_access_rights.append( 158 | MethodAccessRights( 159 | method=right[0], 160 | access_rights=AssociationObjectListParser.parse_access_right( 161 | right[1] 162 | ), 163 | ) 164 | ) 165 | 166 | return parsed_access_rights 167 | 168 | @staticmethod 169 | def parse_entries(object_list): 170 | parsed_objects = list() 171 | for obj in object_list: 172 | interface = enumerations.CosemInterface(obj[0]).value 173 | version = obj[1] 174 | logical_name = cosem.Obis.from_bytes(obj[2]) 175 | access_rights = obj[3] 176 | attribute_access_rights = ( 177 | AssociationObjectListParser.parse_attribute_access_rights( 178 | access_rights[0] 179 | ) 180 | ) 181 | attribute_access_dict = { 182 | access.attribute: access for access in attribute_access_rights 183 | } 184 | method_access_rights = ( 185 | AssociationObjectListParser.parse_method_access_rights(access_rights[1]) 186 | ) 187 | method_access_dict = { 188 | access.method: access for access in method_access_rights 189 | } 190 | 191 | parsed_objects.append( 192 | AssociationObjectListItem( 193 | interface=interface, 194 | version=version, 195 | logical_name=logical_name, 196 | attribute_access_rights=attribute_access_dict, 197 | method_access_rights=method_access_dict, 198 | ) 199 | ) 200 | return parsed_objects 201 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/dlms_cosem/protocol/__init__.py -------------------------------------------------------------------------------- /dlms_cosem/protocol/acse/__init__.py: -------------------------------------------------------------------------------- 1 | from dlms_cosem.protocol.acse.aare import ApplicationAssociationResponse 2 | from dlms_cosem.protocol.acse.aarq import ApplicationAssociationRequest 3 | from dlms_cosem.protocol.acse.base import * 4 | from dlms_cosem.protocol.acse.rlre import ReleaseResponse 5 | from dlms_cosem.protocol.acse.rlrq import ReleaseRequest 6 | from dlms_cosem.protocol.acse.user_information import UserInformation 7 | 8 | __all__ = [ 9 | "ApplicationAssociationRequest", 10 | "ApplicationAssociationResponse", 11 | "ReleaseRequest", 12 | "ReleaseResponse", 13 | "AppContextName", 14 | "MechanismName", 15 | "UserInformation", 16 | ] 17 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/acse/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import enumerations 7 | from dlms_cosem.ber import BER 8 | 9 | 10 | class AbstractAcseApdu(abc.ABC): 11 | @classmethod 12 | @abc.abstractmethod 13 | def from_bytes(cls, source_bytes: bytes): 14 | raise NotImplementedError("") 15 | 16 | @abc.abstractmethod 17 | def to_bytes(self) -> bytes: 18 | raise NotImplementedError("") 19 | 20 | 21 | @attr.s(auto_attribs=True) 22 | class DLMSObjectIdentifier: 23 | """ 24 | The DLMS Association has been assigned a prefix for all of its OBJECT 25 | IDENDIFIERS 26 | """ 27 | 28 | TAG: ClassVar[bytes] = b"\x06" 29 | PREFIX: ClassVar[bytes] = b"\x60\x85\x74\x05\x08" 30 | 31 | 32 | @attr.s(auto_attribs=True) 33 | class AppContextName(DLMSObjectIdentifier): 34 | """ 35 | This defines how to reference objects in the meter and if ciphered APDU:s 36 | are allowed. 37 | """ 38 | 39 | # TODO: Can this be a bit more generalized?? 40 | app_context: ClassVar[int] = 1 41 | 42 | valid_context_ids: ClassVar[List[int]] = [1, 2, 3, 4] 43 | 44 | logical_name_refs: bool = attr.ib(default=True) 45 | ciphered_apdus: bool = attr.ib(default=True) 46 | 47 | @property 48 | def context_id(self) -> int: 49 | if self.logical_name_refs and not self.ciphered_apdus: 50 | return 1 51 | elif not self.logical_name_refs and not self.ciphered_apdus: 52 | return 2 53 | elif self.logical_name_refs and self.ciphered_apdus: 54 | return 3 55 | elif not self.logical_name_refs and self.ciphered_apdus: 56 | return 4 57 | else: 58 | raise ValueError( 59 | "Combination of logical name ref and " "ciphered apdus not possible" 60 | ) 61 | 62 | @classmethod 63 | def from_bytes(cls, _bytes): 64 | tag, length, data = BER.decode(_bytes) 65 | 66 | if tag != DLMSObjectIdentifier.TAG: 67 | raise ValueError( 68 | f"Tag of {tag} is not a valid tag for " f"ObjectIdentifiers" 69 | ) 70 | 71 | context_id = data[-1] 72 | if context_id not in AppContextName.valid_context_ids: 73 | raise ValueError(f"context_id of {context_id} is not valid") 74 | 75 | total_prefix = bytes(data[:-1]) 76 | if total_prefix != ( 77 | DLMSObjectIdentifier.PREFIX + bytes([AppContextName.app_context]) 78 | ): 79 | raise ValueError( 80 | f"Static part of object id it is not correct" 81 | f" according to DLMS: {total_prefix}" 82 | ) 83 | settings_dict = AppContextName.get_settings_by_context_id(context_id) 84 | return cls(**settings_dict) 85 | 86 | def to_bytes(self): 87 | total_data = self.PREFIX + bytes([self.app_context, self.context_id]) 88 | return BER.encode(self.TAG, total_data) 89 | 90 | @staticmethod 91 | def get_settings_by_context_id(context_id): 92 | settings_dict = { 93 | 1: {"logical_name_refs": True, "ciphered_apdus": False}, 94 | 2: {"logical_name_refs": False, "ciphered_apdus": False}, 95 | 3: {"logical_name_refs": True, "ciphered_apdus": True}, 96 | 4: {"logical_name_refs": False, "ciphered_apdus": True}, 97 | } 98 | return settings_dict.get(context_id) 99 | 100 | 101 | @attr.s(auto_attribs=True) 102 | class MechanismName(DLMSObjectIdentifier): 103 | app_context: ClassVar[int] = 2 104 | 105 | mechanism: enumerations.AuthenticationMechanism 106 | 107 | @classmethod 108 | def from_bytes(cls, _bytes: bytes): 109 | """ 110 | Apparently the data in mechanism name is not encoded in BER. 111 | """ 112 | 113 | mechanism_id: int = _bytes[-1] 114 | 115 | total_prefix = bytes(_bytes[:-1]) 116 | if total_prefix != ( 117 | DLMSObjectIdentifier.PREFIX + bytes([MechanismName.app_context]) 118 | ): 119 | raise ValueError( 120 | f"Static part of object id it is not correct" 121 | f" according to DLMS: {total_prefix!r}" 122 | ) 123 | 124 | return cls(mechanism=enumerations.AuthenticationMechanism(mechanism_id)) 125 | 126 | def to_bytes(self): 127 | total_data = self.PREFIX + bytes([self.app_context, self.mechanism.value]) 128 | return total_data 129 | 130 | 131 | def validate_password_type(instance, attribute, value): 132 | 133 | if value not in AuthenticationValue.allowed_password_types: 134 | raise ValueError(f"{value} is not a valid auth value type") 135 | 136 | 137 | @attr.s(auto_attribs=True) 138 | class AuthenticationValue: 139 | """ 140 | Holds "password" in the AARQ and AARE 141 | Can either hold a charstring or a bitstring 142 | """ 143 | 144 | password: bytes = attr.ib(default=b"") 145 | password_type: str = attr.ib(default="chars", validator=[validate_password_type]) 146 | allowed_password_types: ClassVar[List[str]] = ["chars", "bits"] 147 | 148 | @classmethod 149 | def from_bytes(cls, _bytes): 150 | tag, length, data = BER.decode(_bytes) 151 | if tag == b"\x80": 152 | password_type = "chars" 153 | elif tag == b"\x81": 154 | password_type = "bits" 155 | else: 156 | raise ValueError(f"Tag {tag} is not vaild for password") 157 | 158 | return cls(password=data, password_type=password_type) 159 | 160 | def to_bytes(self): 161 | if self.password_type == "chars": 162 | return BER.encode(0x80, self.password) 163 | elif self.password_type == "bits": 164 | return BER.encode(0x81, self.password) 165 | 166 | 167 | @attr.s(auto_attribs=True) 168 | class AuthFunctionalUnit: 169 | """ 170 | Consists of 2 bytes. First byte encodes the number of unused bytes in 171 | the second byte. 172 | So really you just need to set the last bit to 0 to use authentication. 173 | In the green book they use the 0x07 as first byte and 0x80 as last byte. 174 | We will use this to not make it hard to look up. 175 | It is a bit weirdly defined in the Green Book. I interpret is as if the data 176 | exists it is the functional unit 0 (authentication). In examples in the 177 | Green Book they set 0x070x80 as exists. 178 | """ 179 | 180 | authentication: bool = attr.ib(default=False) 181 | 182 | @classmethod 183 | def from_bytes(cls, _bytes): 184 | if len(_bytes) != 2: 185 | raise ValueError( 186 | f"Authentication Functional Unit data should by 2 " 187 | f"bytes. Got: {_bytes}" 188 | ) 189 | last_byte = bool(_bytes[-1]) 190 | return cls(authentication=last_byte) 191 | 192 | def to_bytes(self): 193 | if self.authentication: 194 | return b"\x07\x80" 195 | else: 196 | # when not using authentication this the sender-acse-requirements 197 | # should not be in the data. 198 | return None 199 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/acse/rlre.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import enumerations 7 | from dlms_cosem.ber import BER 8 | from dlms_cosem.protocol.acse.base import AbstractAcseApdu 9 | from dlms_cosem.protocol.acse.user_information import UserInformation 10 | 11 | # TODO: It might be a better approach to give the callable and not the class to make a 12 | # object from bytes. This means we could jack into the creation if needed 13 | # and also using partials and other for integers etc. 14 | release_reason_from_bytes = partial( 15 | enumerations.ReleaseResponseReason.from_bytes, byteorder="big" 16 | ) 17 | 18 | 19 | @attr.s(auto_attribs=True) 20 | class ReleaseResponse(AbstractAcseApdu): 21 | """ 22 | When closing down an Application Association a ReleaseResponse is sent from the 23 | server (meter) after a ReleaseRequest. 24 | 25 | When using ciphering the userinformation can hold an InitateResponse. 26 | 27 | 28 | """ 29 | 30 | TAG: ClassVar[int] = 99 # Application 3 31 | 32 | PARSE_TAGS: ClassVar[Dict[int, Tuple[str, Callable]]] = { 33 | 0x80: ("reason", release_reason_from_bytes), # context specific, constricted 0 34 | 0xBE: ( 35 | "user_information", 36 | UserInformation.from_bytes, 37 | ), # Context specific, constructed 30 38 | } 39 | 40 | reason: Optional[enumerations.ReleaseResponseReason] = attr.ib(default=None) 41 | user_information: Optional[UserInformation] = attr.ib(default=None) 42 | 43 | @classmethod 44 | def from_bytes(cls, source_bytes: bytes): 45 | # put it in a bytearray to be able to pop. 46 | data = bytearray(source_bytes) 47 | 48 | tag = data.pop(0) 49 | if not tag == cls.TAG: 50 | raise ValueError("Bytes are not an RLRE APDU. TAg is not int(96)") 51 | 52 | length = data.pop(0) 53 | 54 | if not len(data) == length: 55 | raise ValueError( 56 | f"The APDU Data lenght does not correspond to length byte, should be {length} but is {len(data)}" 57 | ) 58 | 59 | # Assumes that the protocol-version is 1 and we don't need to decode it 60 | 61 | # Decode the AARQ data 62 | object_dict = dict() 63 | # use the data in tags to go through the bytes and create objects. 64 | while True: 65 | # TODO: this does not take into account when defining objects in dict and not using them. 66 | object_tag = data.pop(0) 67 | object_desc = ReleaseResponse.PARSE_TAGS.get(object_tag, None) 68 | if object_desc is None: 69 | raise ValueError( 70 | f"Could not find object with tag {object_tag} " 71 | f"in RLRQ definition" 72 | ) 73 | 74 | object_length = data.pop(0) 75 | object_data = bytes(data[:object_length]) 76 | data = data[object_length:] 77 | 78 | object_name = object_desc[0] 79 | call: Callable = object_desc[1] 80 | 81 | if call is not None: 82 | 83 | object_data = call(object_data) 84 | 85 | object_dict[object_name] = object_data 86 | 87 | if len(data) <= 0: 88 | break 89 | 90 | return cls(**object_dict) 91 | 92 | def to_bytes(self) -> bytes: 93 | rlrq_data = bytearray() 94 | # default value of protocol_version is 1. Only decode if other than 1 95 | 96 | if self.reason is not None: 97 | rlrq_data.extend(BER.encode(0x80, self.reason.value.to_bytes(1, "big"))) 98 | if self.user_information is not None: 99 | rlrq_data.extend(BER.encode(0xBE, self.user_information.to_bytes())) 100 | return BER.encode(self.TAG, rlrq_data) 101 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/acse/rlrq.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import enumerations 7 | from dlms_cosem.ber import BER 8 | from dlms_cosem.protocol.acse.base import AbstractAcseApdu 9 | from dlms_cosem.protocol.acse.user_information import UserInformation 10 | 11 | # TODO: It might be a better approach to give the callable and not the class to make a 12 | # object from bytes. This means we could jack into the creation if needed 13 | # and also using partials and other for integers etc. 14 | release_reason_from_bytes = partial( 15 | enumerations.ReleaseRequestReason.from_bytes, byteorder="big" 16 | ) 17 | 18 | 19 | @attr.s(auto_attribs=True) 20 | class ReleaseRequest(AbstractAcseApdu): 21 | """ 22 | When closing down an Application Association a ReleaseRequest is sent. 23 | 24 | When using ciphering the userinformation can hold an InitiateRequest. 25 | Even if there is no dedicated key the `InitiateRequestApdu` should be protected 26 | as above if there is need to protect the RLRQ 27 | 28 | 29 | """ 30 | 31 | TAG: ClassVar[int] = 98 # Application 2 32 | 33 | PARSE_TAGS: ClassVar[Dict[int, Tuple[str, Callable]]] = { 34 | 0x80: ("reason", release_reason_from_bytes), # context specific, constricted 0 35 | 0xBE: ( 36 | "user_information", 37 | UserInformation.from_bytes, 38 | ), # Context specific, constructed 30 39 | } 40 | 41 | reason: Optional[enumerations.ReleaseRequestReason] = attr.ib(default=None) 42 | user_information: Optional[UserInformation] = attr.ib(default=None) 43 | 44 | @classmethod 45 | def from_bytes(cls, source_bytes: bytes): 46 | # put it in a bytearray to be able to pop. 47 | rlrq_data = bytearray(source_bytes) 48 | 49 | rlrq_tag = rlrq_data.pop(0) 50 | if not rlrq_tag == cls.TAG: 51 | raise ValueError("Bytes are not an RLRQ APDU. TAg is not int(98)") 52 | 53 | rlrq_length = rlrq_data.pop(0) 54 | 55 | if not len(rlrq_data) == rlrq_length: 56 | raise ValueError( 57 | "The APDU Data lenght does not correspond " "to length byte" 58 | ) 59 | 60 | # Assumes that the protocol-version is 1 and we don't need to decode it 61 | 62 | # Decode the AARQ data 63 | object_dict = dict() 64 | # use the data in tags to go through the bytes and create objects. 65 | while True: 66 | object_tag = rlrq_data.pop(0) 67 | object_desc = ReleaseRequest.PARSE_TAGS.get(object_tag, None) 68 | if object_desc is None: 69 | raise ValueError( 70 | f"Could not find object with tag {object_tag} " 71 | f"in RLRQ definition" 72 | ) 73 | 74 | object_length = rlrq_data.pop(0) 75 | object_data = bytes(rlrq_data[:object_length]) 76 | rlrq_data = rlrq_data[object_length:] 77 | 78 | object_name = object_desc[0] 79 | call: Callable = object_desc[1] 80 | 81 | if call is not None: 82 | 83 | object_data = call(object_data) 84 | 85 | object_dict[object_name] = object_data 86 | 87 | if len(rlrq_data) <= 0: 88 | break 89 | 90 | return cls(**object_dict) 91 | 92 | def to_bytes(self) -> bytes: 93 | rlrq_data = bytearray() 94 | # default value of protocol_version is 1. Only decode if other than 1 95 | 96 | if self.reason is not None: 97 | rlrq_data.extend(BER.encode(0x80, self.reason.value.to_bytes(1, "big"))) 98 | if self.user_information is not None: 99 | rlrq_data.extend(BER.encode(0xBE, self.user_information.to_bytes())) 100 | return BER.encode(self.TAG, rlrq_data) 101 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/acse/user_information.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | import attr 4 | 5 | from dlms_cosem import ber 6 | 7 | 8 | @attr.s(auto_attribs=True) 9 | class UserInformation: 10 | """ 11 | UserInformation holds InitiateRequests for AARQ and InitiateResponse for AARE. 12 | In case of error it can hold an ConformedServiceErrorAPDU in the AARE. 13 | In case of encryption the user-information holds ciphered APDUs. Either global-ciper 14 | or dedicated-cipher #TODO: is dedicated reasonable since no association has started. 15 | 16 | All the APDUs held by the user information is encoded in X-ADR but the AARQ/AARE are 17 | encoded in BER. To be able to make it distinct the content of the endoed XDLMS-APDU 18 | is encoded as an OctetString in BER. 19 | 20 | """ 21 | 22 | tag = b"\x04" # is encoded as an octetstring 23 | 24 | content: Any 25 | # Union[ 26 | # xdlms.InitiateRequestApdu, 27 | # xdlms.InitiateResponseApdu, 28 | # xdlms.ConfirmedServiceErrorApdu, 29 | # xdlms.GlobalCipherInitiateRequest 30 | # ] 31 | 32 | @classmethod 33 | def from_bytes(cls, _bytes): 34 | from dlms_cosem.protocol import xdlms 35 | 36 | tag, length, data = ber.BER.decode(_bytes) 37 | if tag != UserInformation.tag: 38 | raise ValueError( 39 | f"The tag for UserInformation data should be 0x04" f"not {tag!r}" 40 | ) 41 | 42 | if data[0] == 1: 43 | return cls(content=xdlms.InitiateRequest.from_bytes(data)) 44 | elif data[0] == 8: 45 | return cls(content=xdlms.InitiateResponse.from_bytes(data)) 46 | elif data[0] == 14: 47 | return cls(content=xdlms.ConfirmedServiceError.from_bytes(data)) 48 | elif data[0] == 33: 49 | return cls(content=xdlms.GlobalCipherInitiateRequest.from_bytes(data)) 50 | elif data[0] == 40: 51 | return cls(content=xdlms.GlobalCipherInitiateResponse.from_bytes(data)) 52 | else: 53 | raise ValueError( 54 | f"Not able to find a proper data tag in UserInformation. Got {data[0]}" 55 | ) 56 | 57 | def to_bytes(self): 58 | return ber.BER.encode(self.tag, self.content.to_bytes()) 59 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/wrappers.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | 4 | @attr.s(auto_attribs=True) 5 | class WrapperHeader: 6 | """ 7 | The wrapper header contains 4 parts. Each is an unsigned 16 bit integer. 8 | 9 | * version 10 | * Source wPort 11 | * Destination wPort 12 | * length of the dlms data transferred. 13 | 14 | Reserved wrapper port numbers: 15 | 16 | Client side reserved addresses: 17 | 18 | ========================== ============= 19 | Description wPort number 20 | ========================= ============= 21 | No-station 0 22 | Client Management Process 1 23 | Public Client 16 24 | Open for client SAP assignment 2-15, 17-255 25 | 26 | Server side reserved addresses: 27 | 28 | ========================== ============= 29 | Description wPort number 30 | ========================= ============= 31 | No-station 0 32 | Management Logical Device 1 33 | Reserved 2-15 34 | Open for client SAP assignment 16-126 35 | All-station (Broad Cast) 127 36 | 37 | :param version: Version of the wrapper. Currently value 0x0001 is used. 38 | :param source_wport: wPort (Wrapper Port) number for the sending 39 | DLMS/COSEM Application Entity (AE) 40 | :param destination_wport: wPort (Wrapper Port) number for the receiving 41 | DLMS/COSEM Application Entity (AE) 42 | :param length: Length of data in xDLMS APDU to be transported. 43 | 44 | """ 45 | 46 | source_wport: int 47 | destination_wport: int 48 | length: int 49 | version: int = attr.ib(default=1) 50 | 51 | def to_bytes(self): 52 | _version = self.version.to_bytes(2, "big") 53 | _source_wport = self.source_wport.to_bytes(2, "big") 54 | _destination_wport = self.destination_wport.to_bytes(2, "big") 55 | _length = self.length.to_bytes(2, "big") 56 | 57 | return _version + _source_wport + _destination_wport + _length 58 | 59 | @classmethod 60 | def from_bytes(cls, in_data): 61 | if len(in_data) != 8: 62 | raise ValueError( 63 | f"Wrapper Header can only consists of 8 bytes and " 64 | f"got {len(in_data)}" 65 | ) 66 | version = int.from_bytes(in_data[0:2], "big") 67 | source_wport = int.from_bytes(in_data[2:4], "big") 68 | destination_wport = int.from_bytes(in_data[4:6], "big") 69 | length = int.from_bytes(in_data[6:8], "big") 70 | 71 | return cls(source_wport, destination_wport, length, version) 72 | 73 | 74 | @attr.s(auto_attribs=True) 75 | class WrapperProtocolDataUnit: 76 | """ 77 | 78 | When sending DLMS data over UDP or TCP you need to include an additional 79 | wrapper to: 80 | 81 | * Provide additional addressing functionality on top of UDP/TCP port. (Since 82 | a physical device can host several logical devices) 83 | * Describe the length of the data sent. Especially for TCP where the data 84 | can be split up in several packets. 85 | 86 | :param data: The bytes of the xDLMS APDU transported. 87 | :param WrapperHeader wrapper_header: Wrapper header to declare additional 88 | information on how to handle the data sent. 89 | """ 90 | 91 | data: bytes 92 | wrapper_header: WrapperHeader 93 | 94 | def to_bytes(self): 95 | return self.wrapper_header.to_bytes() + self.data 96 | 97 | @classmethod 98 | def from_bytes(cls, in_data): 99 | wrapper_header_data = in_data[0:8] 100 | data = in_data[8:] 101 | 102 | wrapper_header = WrapperHeader.from_bytes(wrapper_header_data) 103 | 104 | data_length = len(data) 105 | if not wrapper_header.length == data_length: 106 | raise ValueError( 107 | ( 108 | f"Length of data in Wrapper Protocol Data Unit class " 109 | f"{cls.__class__.__name__}, ({data_length}) does not match " 110 | f"the length parameter in the Wrapper Header " 111 | f"({wrapper_header.length})" 112 | ) 113 | ) 114 | 115 | return cls(data, wrapper_header) 116 | 117 | 118 | class DlmsUdpMessage(WrapperProtocolDataUnit): 119 | """ 120 | Handle UPD messages with DLMS APDU content 121 | """ 122 | 123 | pass 124 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/__init__.py: -------------------------------------------------------------------------------- 1 | from dlms_cosem.protocol.xdlms.action import ( 2 | ActionRequestFactory, 3 | ActionRequestNormal, 4 | ActionResponseFactory, 5 | ActionResponseNormal, 6 | ActionResponseNormalWithData, 7 | ActionResponseNormalWithError, 8 | ) 9 | from dlms_cosem.protocol.xdlms.confirmed_service_error import ConfirmedServiceError 10 | from dlms_cosem.protocol.xdlms.conformance import Conformance 11 | from dlms_cosem.protocol.xdlms.data_notification import DataNotification 12 | from dlms_cosem.protocol.xdlms.exception_response import ExceptionResponse 13 | from dlms_cosem.protocol.xdlms.general_global_cipher import GeneralGlobalCipher 14 | from dlms_cosem.protocol.xdlms.get import ( 15 | GetRequestFactory, 16 | GetRequestNext, 17 | GetRequestNormal, 18 | GetRequestWithList, 19 | GetResponseFactory, 20 | GetResponseLastBlock, 21 | GetResponseLastBlockWithError, 22 | GetResponseNormal, 23 | GetResponseNormalWithError, 24 | GetResponseWithBlock, 25 | GetResponseWithList, 26 | ) 27 | from dlms_cosem.protocol.xdlms.initiate_request import ( 28 | GlobalCipherInitiateRequest, 29 | InitiateRequest, 30 | ) 31 | from dlms_cosem.protocol.xdlms.initiate_response import ( 32 | GlobalCipherInitiateResponse, 33 | InitiateResponse, 34 | ) 35 | from dlms_cosem.protocol.xdlms.invoke_id_and_priority import InvokeIdAndPriority 36 | from dlms_cosem.protocol.xdlms.set import ( 37 | SetRequestFactory, 38 | SetRequestNormal, 39 | SetResponseFactory, 40 | SetResponseNormal, 41 | ) 42 | 43 | __all__ = [ 44 | "InitiateRequest", 45 | "DataNotification", 46 | "GeneralGlobalCipher", 47 | "InitiateResponse", 48 | "ConfirmedServiceError", 49 | "Conformance", 50 | "GetRequestNormal", 51 | "GetRequestWithList", 52 | "GetRequestNext", 53 | "GetResponseNormal", 54 | "GetResponseNormalWithError", 55 | "GetResponseWithBlock", 56 | "GetResponseLastBlock", 57 | "GetResponseLastBlockWithError", 58 | "GetResponseWithList", 59 | "GetRequestFactory", 60 | "GetResponseFactory", 61 | "SetResponseNormal", 62 | "SetResponseFactory", 63 | "SetRequestNormal", 64 | "SetRequestFactory", 65 | "ExceptionResponse", 66 | "GlobalCipherInitiateRequest", 67 | "GlobalCipherInitiateResponse", 68 | "ActionResponseNormal", 69 | "ActionResponseNormalWithData", 70 | "ActionResponseNormalWithError", 71 | "ActionResponseFactory", 72 | "ActionRequestNormal", 73 | "ActionRequestFactory", 74 | "InvokeIdAndPriority", 75 | ] 76 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class AbstractXDlmsApdu(abc.ABC): 5 | @classmethod 6 | @abc.abstractmethod 7 | def from_bytes(cls, source_bytes: bytes): 8 | raise NotImplementedError() 9 | 10 | @abc.abstractmethod 11 | def to_bytes(self) -> bytes: 12 | raise NotImplementedError() 13 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/confirmed_service_error.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import enumerations 7 | from dlms_cosem.a_xdr import Attribute, AXdrDecoder, Choice, EncodingConf 8 | from dlms_cosem.protocol.xdlms.base import AbstractXDlmsApdu 9 | 10 | 11 | class ErrorFactory: 12 | 13 | ERROR_TYPE_MAP: ClassVar[Dict[int, Type[IntEnum]]] = { 14 | 0: enumerations.ApplicationReferenceError, 15 | 1: enumerations.HardwareResourceError, 16 | 2: enumerations.VdeStateError, 17 | 3: enumerations.ServiceError, 18 | 4: enumerations.DefinitionError, 19 | 5: enumerations.AccessError, 20 | 6: enumerations.InitiateError, 21 | 7: enumerations.LoadDataError, 22 | 8: enumerations.DataScopeError, 23 | 9: enumerations.DataScopeError, 24 | 10: enumerations.OtherError, 25 | } 26 | 27 | @classmethod 28 | def get_error_type(cls, tag: int): 29 | return cls.ERROR_TYPE_MAP[tag] 30 | 31 | 32 | def make_error(source_bytes: bytes): 33 | if len(source_bytes) != 2: 34 | raise ValueError(f"Length needs to be 2 not {len(source_bytes)}") 35 | error_tag = source_bytes[0] 36 | error_type = ErrorFactory.get_error_type(error_tag) 37 | return error_type(source_bytes[1]) 38 | 39 | 40 | @attr.s(auto_attribs=True) 41 | class ConfirmedServiceError(AbstractXDlmsApdu): 42 | 43 | TAG: ClassVar[int] = 14 44 | 45 | ENCODING_CONF: ClassVar[EncodingConf] = EncodingConf( 46 | attributes=[ 47 | Choice( 48 | choices={ 49 | b"\x01": Attribute( 50 | attribute_name="error", create_instance=make_error, length=2 51 | ), 52 | b"\x05": Attribute( 53 | attribute_name="error", create_instance=make_error, length=2 54 | ), 55 | b"\x06": Attribute( 56 | attribute_name="error", create_instance=make_error, length=2 57 | ), 58 | } 59 | ) 60 | ] 61 | ) 62 | 63 | error: IntEnum 64 | 65 | @classmethod 66 | def from_bytes(cls, source_bytes: bytes): 67 | data = bytearray(source_bytes) 68 | tag = data.pop(0) 69 | if tag != cls.TAG: 70 | raise ValueError( 71 | f"Tag for ConformedServiceError should be {cls.TAG} not {tag}" 72 | ) 73 | decoder = AXdrDecoder(cls.ENCODING_CONF) 74 | result = decoder.decode(data) 75 | 76 | return cls(**result) 77 | 78 | def to_bytes(self) -> bytes: 79 | # TODO: No good handling of reversing choice in A-XDR. Just setting it 80 | # to 01 InitiateError 81 | 82 | rev_error_map = {y: x for x, y in ErrorFactory.ERROR_TYPE_MAP.items()} 83 | error_type_id = rev_error_map[type(self.error)] 84 | 85 | return bytes([self.TAG, 1, error_type_id, self.error.value]) 86 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/conformance.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | import attr 4 | 5 | # TODO: when using ciphered apdus we will get other apdus. (33 64) global or dedicated cipered iniitate requests 6 | 7 | 8 | @attr.s(auto_attribs=True) 9 | class Conformance: 10 | """ 11 | Holds information about the supported services in a DLMS association. 12 | Is used to send the propsed conformance in AARQ and to send back the negotiated 13 | conformance in the AARE. 14 | 15 | Only LN referenceing is supported. 16 | 17 | It specifes Conformance ::= [Application 31] Implicit BIT STRING (24) 18 | Conformance should be BER encoded. 19 | Having tags that are higher than 31 in BER encoding gives multi byte tags. 20 | Where the first tag byte fills all tag values. And the consecutive bytes 21 | represents the tag number. If the number cannot be contined in one extra byte the 22 | msb should be set to 1 and bytes added with msb set to 1 until the tag number can 23 | fit. The last byte should have the msb set to 0 24 | 25 | So for Application 31 the tag is 0x01011111 + 0b00011111 = 0x5f 0x1F. 26 | 27 | # TODO: how to code than..... 28 | 29 | But in the example they encode it {0x5F 0x1F {length=0x04} {number of unused bits last byte} {3x byte for bitstring(24}} 30 | The ASN.1 shoudl give 0x5 31 | 32 | The bit placement for the conformance flags are also a bit weird in the standard. 33 | Since they count the bits from left to right. So bit 0 is the MSB. 34 | The placement in this class sets them as bit 0 is LSB. 35 | 36 | # TODO: Should also be set up at the assosiation to track the negotiated. 37 | """ 38 | 39 | general_protection: bool = attr.ib(default=False) 40 | general_block_transfer: bool = attr.ib(default=False) 41 | delta_value_encoding: bool = attr.ib(default=False) 42 | attribute_0_supported_with_set: bool = attr.ib(default=False) 43 | priority_management_supported: bool = attr.ib(default=False) 44 | attribute_0_supported_with_get: bool = attr.ib(default=False) 45 | block_transfer_with_get_or_read: bool = attr.ib(default=False) 46 | block_transfer_with_set_or_write: bool = attr.ib(default=False) 47 | block_transfer_with_action: bool = attr.ib(default=False) 48 | multiple_references: bool = attr.ib(default=False) 49 | data_notification: bool = attr.ib(default=False) 50 | access: bool = attr.ib(default=False) 51 | get: bool = attr.ib(default=False) 52 | set: bool = attr.ib(default=False) 53 | selective_access: bool = attr.ib(default=False) 54 | event_notification: bool = attr.ib(default=False) 55 | action: bool = attr.ib(default=False) 56 | 57 | # bit numbering starts at 0 58 | conformance_bit_position: ClassVar[Dict[str, int]] = { 59 | "general_protection": 22, 60 | "general_block_transfer": 21, 61 | "delta_value_encoding": 17, 62 | "attribute_0_supported_with_set": 15, 63 | "priority_management_supported": 14, 64 | "attribute_0_supported_with_get": 13, 65 | "block_transfer_with_get_or_read": 12, 66 | "block_transfer_with_set_or_write": 11, 67 | "block_transfer_with_action": 10, 68 | "multiple_references": 9, 69 | "data_notification": 7, 70 | "access": 6, 71 | "get": 4, 72 | "set": 3, 73 | "selective_access": 2, 74 | "event_notification": 1, 75 | "action": 0, 76 | } 77 | 78 | @classmethod 79 | def from_bytes(cls, in_bytes: bytes): 80 | in_dict = dict() 81 | integer_representation = int.from_bytes(in_bytes[1:], "big") 82 | for attribute in Conformance.conformance_bit_position.keys(): 83 | in_dict[attribute] = bool( 84 | integer_representation 85 | & (1 << Conformance.conformance_bit_position[attribute]) 86 | ) 87 | return cls(**in_dict) 88 | 89 | def to_bytes(self): 90 | out = 0 91 | for attribute, position in Conformance.conformance_bit_position.items(): 92 | flag_is_set = getattr(self, attribute) 93 | if flag_is_set: 94 | out += 1 << position 95 | # It is a bit string so need to encode how many bits that are unused in the 96 | # last byte. Its none so we can just put 0x00 infront. 97 | return b"\x00" + out.to_bytes(3, "big") 98 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/data_notification.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import * 3 | 4 | import attr 5 | 6 | import dlms_cosem.time as dlmstime 7 | from dlms_cosem.protocol.xdlms.base import AbstractXDlmsApdu 8 | 9 | 10 | @attr.s(auto_attribs=True) 11 | class LongInvokeIdAndPriority: 12 | """ 13 | Unsigned 32 bits 14 | 15 | - bit 0-23: Long Invoke ID 16 | - bit 25-27: Reserved 17 | - bit 28: Self descriptive -> 0=Not Self Descriptive, 1= Self-descriptive 18 | - bit 29: Processing options -> 0 = Continue on Error, 1=Break on Error 19 | - bit 30: Service class -> 0 = Unconfirmed, 1 = Confirmed 20 | - bit 31 Priority, -> 0 = normal, 1 = high. 21 | 22 | :param int long_invoke_id: Long Invoke ID 23 | :param bool self_descriptive: Indicates if self descriptive `DEFAULT=False` 24 | :param bool confirmed: Indicates if confirmed. `DEFAULT=False` 25 | :param bool prioritized: Indicates if prioritized. `DEFAULT=False` 26 | :param bool break_on_error: Indicates id should break in error. `DEFAULT=True` 27 | 28 | """ 29 | 30 | long_invoke_id: int 31 | prioritized: bool = attr.ib(default=False) 32 | confirmed: bool = attr.ib(default=False) 33 | self_descriptive: bool = attr.ib(default=False) 34 | break_on_error: bool = attr.ib(default=False) 35 | 36 | @classmethod 37 | def from_bytes(cls, bytes_data): 38 | if len(bytes_data) != 4: 39 | raise ValueError( 40 | f"LongInvokeIdAndPriority is 4 bytes long," 41 | f" received: {len(bytes_data)}" 42 | ) 43 | 44 | long_invoke_id = int.from_bytes(bytes_data[1:], "big") 45 | status_byte = bytes_data[0] 46 | prioritized = bool(status_byte & 0b10000000) 47 | confirmed = bool(status_byte & 0b01000000) 48 | break_on_error = bool(status_byte & 0b00100000) 49 | self_descriptive = bool(status_byte & 0b00010000) 50 | 51 | return cls( 52 | long_invoke_id=long_invoke_id, 53 | prioritized=prioritized, 54 | confirmed=confirmed, 55 | break_on_error=break_on_error, 56 | self_descriptive=self_descriptive, 57 | ) 58 | 59 | def to_bytes(self) -> bytes: 60 | status = 0 61 | if self.prioritized: 62 | status = status | 0b10000000 63 | if self.confirmed: 64 | status = status | 0b01000000 65 | if self.break_on_error: 66 | status = status | 0b00100000 67 | if self.self_descriptive: 68 | status = status | 0b00010000 69 | return status.to_bytes(1, "big") + self.long_invoke_id.to_bytes(3, "big") 70 | 71 | 72 | @attr.s(auto_attribs=True) 73 | class DataNotification(AbstractXDlmsApdu): 74 | """ 75 | The DataNotification APDU is used by the DataNotification service. 76 | It is used to push data from a server (meter) to the client (amr-system). 77 | It is an unconfirmable service. 78 | 79 | A DataNotification APDU, if to large, can be sent using the general block 80 | transfer method. 81 | 82 | :param `LongInvokeAndPriority` long_invoke_id_and_priority: The long invoke 83 | id is a reference to the server invocation. self_descriptive, 84 | break_on_error and prioritized are not used for Datanotifications. 85 | :param datetime.datetime date_time: Indicates the time the DataNotification 86 | was sent. Is optional. 87 | :param `bytes` body: Push data. 88 | """ 89 | 90 | TAG = 15 91 | 92 | long_invoke_id_and_priority: LongInvokeIdAndPriority 93 | date_time: Optional[datetime.datetime] 94 | body: bytes 95 | 96 | @classmethod 97 | def from_bytes(cls, source_bytes: bytes): 98 | data = bytearray(source_bytes) 99 | tag = data.pop(0) 100 | if tag != cls.TAG: 101 | raise ValueError( 102 | f"Data is not a DataNotification APDU. Expected tag={cls.TAG} but got {tag}" 103 | ) 104 | long_invoke_id_data = data[:4] 105 | long_invoke_id = LongInvokeIdAndPriority.from_bytes(bytes(long_invoke_id_data)) 106 | data = data[4:] 107 | has_datetime = bool(data.pop(0)) 108 | if has_datetime: 109 | dn_datetime_data = data[:12] 110 | data = data[12:] 111 | dn_datetime, _ = dlmstime.datetime_from_bytes(dn_datetime_data) 112 | else: 113 | dn_datetime = None 114 | return cls( 115 | long_invoke_id_and_priority=long_invoke_id, 116 | date_time=dn_datetime, 117 | body=bytes(data), 118 | ) 119 | 120 | def to_bytes(self) -> bytes: 121 | out = bytearray() 122 | out.append(self.TAG) 123 | out.extend(self.long_invoke_id_and_priority.to_bytes()) 124 | if self.date_time: 125 | out.extend(b"\x01") 126 | out.extend(dlmstime.datetime_to_bytes(self.date_time)) 127 | else: 128 | out.extend(b"\x00") 129 | out.extend(self.body) 130 | return bytes(out) 131 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/exception_response.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import enumerations 7 | 8 | 9 | @attr.s(auto_attribs=True) 10 | class ExceptionResponse: 11 | TAG: ClassVar[int] = 216 12 | 13 | state_error: enumerations.StateException 14 | service_error: enumerations.ServiceException 15 | invocation_counter_data: Optional[int] = attr.ib(default=None) 16 | 17 | @classmethod 18 | def from_bytes(cls, source_bytes: bytes): 19 | data = bytearray(source_bytes) 20 | tag = data.pop(0) 21 | if tag != cls.TAG: 22 | raise ValueError( 23 | f"Tag for ExceptionResponse is not {cls.TAG}. Got {tag} instead." 24 | ) 25 | state_error = enumerations.StateException(data.pop(0)) 26 | service_error = enumerations.ServiceException(data.pop(0)) 27 | 28 | if service_error == enumerations.ServiceException.INVOCATION_COUNTER_ERROR: 29 | invocation_counter_data = int.from_bytes(data, "big") 30 | else: 31 | invocation_counter_data = None 32 | 33 | return cls(state_error, service_error, invocation_counter_data) 34 | 35 | def to_bytes(self): 36 | if not self.invocation_counter_data: 37 | return bytes([self.TAG, self.state_error, self.service_error]) 38 | return bytes( 39 | [ 40 | self.TAG, 41 | self.state_error, 42 | self.service_error, 43 | self.invocation_counter_data, 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/general_global_cipher.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Optional 3 | 4 | import attr 5 | 6 | from dlms_cosem import a_xdr 7 | from dlms_cosem.dlms_data import OctetStringData 8 | from dlms_cosem.protocol.xdlms.base import AbstractXDlmsApdu 9 | from dlms_cosem.security import SecurityControlField, decrypt 10 | 11 | int_from_bytes = partial(int.from_bytes, "big") 12 | 13 | 14 | @attr.s(auto_attribs=True) 15 | class GeneralGlobalCipher(AbstractXDlmsApdu): 16 | """ 17 | The general-global-cipher APDU can be used to cipher other APDUs with 18 | either the global key or the dedicated key. 19 | 20 | The additional authenticated data to use for decryption is depending on the 21 | portection applied. 22 | 23 | Encrypted and authenticated: Security Control Field || Authentication Key 24 | Only authenticated: Security Control Field || Authentication Key || Ciphered Text 25 | Only encrypted: b'' 26 | No protection: b'' 27 | 28 | """ 29 | 30 | TAG = 219 31 | NAME = "general-glo-cipher" 32 | 33 | ENCODING_CONF = a_xdr.EncodingConf( 34 | [ 35 | a_xdr.Attribute( 36 | attribute_name="system_title", create_instance=OctetStringData 37 | ), 38 | a_xdr.Attribute( 39 | attribute_name="ciphered_content", 40 | create_instance=OctetStringData.from_bytes, 41 | ), 42 | ] 43 | ) 44 | 45 | # Some implementations does not send the system_title. But it seems like it is against the standard. 46 | system_title: Optional[bytes] 47 | security_control: SecurityControlField 48 | invocation_counter: int 49 | ciphered_text: bytes 50 | 51 | @classmethod 52 | def from_bytes(cls, source_bytes: bytes): 53 | data = bytearray(source_bytes) 54 | tag = data.pop(0) 55 | if tag != cls.TAG: 56 | raise ValueError(f"Tag not as expected. Expected: {cls.TAG} but got {tag}") 57 | decoder = a_xdr.AXdrDecoder(encoding_conf=cls.ENCODING_CONF) 58 | in_dict = decoder.decode(data) 59 | system_title = in_dict["system_title"].value 60 | ciphered_content = in_dict["ciphered_content"].value 61 | security_control = SecurityControlField.from_bytes( 62 | ciphered_content.pop(0).to_bytes(1, "big") 63 | ) 64 | invocation_counter = int.from_bytes(ciphered_content[:4], "big") 65 | ciphered_text = bytes(ciphered_content[4:]) 66 | return cls(system_title, security_control, invocation_counter, ciphered_text) 67 | 68 | def to_bytes(self) -> bytes: 69 | out = bytearray() 70 | out.append(self.TAG) 71 | if self.system_title: 72 | out.append(len(self.system_title)) 73 | out.extend(self.system_title) 74 | else: 75 | out.extend(b"\x00") 76 | out.append( 77 | len( 78 | self.security_control.to_bytes() 79 | + self.invocation_counter.to_bytes(4, "big") 80 | + self.ciphered_text 81 | ) 82 | ) 83 | out.extend(self.security_control.to_bytes()) 84 | out.extend(self.invocation_counter.to_bytes(4, "big")) 85 | out.extend(self.ciphered_text) 86 | return bytes(out) 87 | 88 | def to_plain_apdu(self, encryption_key, authentication_key) -> bytes: 89 | plain_text = decrypt( 90 | security_control=self.security_control, 91 | key=encryption_key, 92 | auth_key=authentication_key, 93 | invocation_counter=self.invocation_counter, 94 | cipher_text=self.ciphered_text, 95 | system_title=self.system_title, 96 | ) 97 | 98 | return bytes(plain_text) 99 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/initiate_request.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import * 3 | 4 | import attr 5 | 6 | from dlms_cosem import a_xdr, dlms_data, security 7 | from dlms_cosem.protocol import xdlms 8 | from dlms_cosem.protocol.xdlms.base import AbstractXDlmsApdu 9 | from dlms_cosem.protocol.xdlms.conformance import Conformance 10 | 11 | int_from_bytes = partial(int.from_bytes, byteorder="big") 12 | 13 | 14 | @attr.s(auto_attribs=True) 15 | class InitiateRequest(AbstractXDlmsApdu): 16 | """ 17 | InitiateRequest ::= SEQUENCE { 18 | dedicated-key: OCTET STRING OPTIONAL 19 | response-allowed: BOOLEAN DEFAULT TRUE 20 | proposed-quality-of-service: IMPLICIT Integer8 OPTIONAL 21 | proposed-dlms-version-number: Integer8 # Always 6? 22 | proposed-conformance: Conformance 23 | client-max-receive-pdu-size: Unsigned16 24 | } 25 | """ 26 | 27 | TAG: ClassVar[int] = 0x01 # initiateRequest XDLMS-APDU Choice. 28 | 29 | ENCODING_CONF = a_xdr.EncodingConf( 30 | [ 31 | a_xdr.Attribute( 32 | attribute_name="dedicated_key", 33 | create_instance=dlms_data.OctetStringData.from_bytes, 34 | optional=True, 35 | ), 36 | a_xdr.Attribute( 37 | attribute_name="response_allowed", create_instance=bool, default=True 38 | ), 39 | a_xdr.Attribute( 40 | attribute_name="proposed_quality_of_service", 41 | create_instance=int_from_bytes, 42 | length=1, 43 | ), 44 | a_xdr.Attribute( 45 | attribute_name="proposed_dlms_version_number", 46 | create_instance=int_from_bytes, 47 | length=1, 48 | ), 49 | a_xdr.Attribute( 50 | attribute_name="rest", 51 | create_instance=dlms_data.OctetStringData.from_bytes, 52 | length=9, 53 | ), 54 | ] 55 | ) 56 | 57 | proposed_conformance: Conformance 58 | proposed_quality_of_service: Optional[int] = attr.ib(default=None) 59 | client_max_receive_pdu_size: int = attr.ib(default=65535) 60 | proposed_dlms_version_number: int = attr.ib(default=6) 61 | response_allowed: bool = attr.ib(default=True) 62 | dedicated_key: Optional[bytes] = attr.ib(default=None) 63 | 64 | @classmethod 65 | def from_bytes(cls, _bytes: bytes): 66 | # There is weird decoding here since it is mixed X-ADS and BER.... 67 | data = bytearray(_bytes) 68 | apdu_tag = data.pop(0) 69 | if apdu_tag != 0x01: 70 | raise ValueError( 71 | f"Data is not a InitiateReques APDU, got apdu tag {apdu_tag}" 72 | ) 73 | 74 | decoder = a_xdr.AXdrDecoder(cls.ENCODING_CONF) 75 | object_dict = decoder.decode(data) 76 | 77 | # Since the initiate request mixes a-xdr and ber encoding we make some pragmatic 78 | # one-off handling of that case. 79 | 80 | rest = bytearray(object_dict.pop("rest").value) 81 | # rest contains ber endoced propesed conformance and max reciec pdu 82 | 83 | conformance_tag = rest[:2] 84 | if conformance_tag != b"\x5f\x1f": 85 | raise ValueError( 86 | f"Didnt receive conformance tag correcly, got {conformance_tag!r}" 87 | ) 88 | conformance = xdlms.Conformance.from_bytes(data[-5:-2]) 89 | max_pdu_size = int.from_bytes(data[-2:], "big") 90 | dedicated_key_obj = object_dict.pop("dedicated_key") 91 | if dedicated_key_obj: 92 | dedicated_key = bytes(dedicated_key_obj.value) 93 | else: 94 | dedicated_key = None 95 | return cls( 96 | **object_dict, 97 | dedicated_key=dedicated_key, 98 | proposed_conformance=conformance, 99 | client_max_receive_pdu_size=max_pdu_size, 100 | ) 101 | 102 | def to_bytes(self): 103 | # Since the initiate request mixes a-xdr and ber encoding we make some pragmatic 104 | # one-off handling of that case. 105 | out = bytearray() 106 | out.append(self.TAG) 107 | if self.dedicated_key: 108 | out.append(0x01) 109 | out.append(len(self.dedicated_key)) 110 | out.extend(self.dedicated_key) 111 | else: 112 | out.append(0x00) 113 | out.append(0x00) 114 | out.append(0x00) 115 | out.append(0x06) 116 | out.extend(b"_\x1f\x04") 117 | out.extend(self.proposed_conformance.to_bytes()) 118 | out.extend(self.client_max_receive_pdu_size.to_bytes(2, "big")) 119 | return bytes(out) 120 | 121 | 122 | @attr.s(auto_attribs=True) 123 | class GlobalCipherInitiateRequest(AbstractXDlmsApdu): 124 | TAG: ClassVar[int] = 33 125 | 126 | security_control: security.SecurityControlField 127 | invocation_counter: int 128 | ciphered_text: bytes 129 | 130 | @classmethod 131 | def from_bytes(cls, source_bytes: bytes): 132 | data = bytearray(source_bytes) 133 | tag = data.pop(0) 134 | if tag != cls.TAG: 135 | raise ValueError(f"Tag is not correct. Should be {cls.TAG} but got {tag}") 136 | 137 | length = data.pop(0) 138 | if length != len(data): 139 | raise ValueError(f"Octetstring is not of correct length") 140 | 141 | security_control = security.SecurityControlField.from_bytes( 142 | data.pop(0).to_bytes(1, "big") 143 | ) 144 | invocation_counter = int.from_bytes(data[:4], "big") 145 | ciphered_text = bytes(data[4:]) 146 | 147 | return cls(security_control, invocation_counter, ciphered_text) 148 | 149 | def to_bytes(self): 150 | out = bytearray() 151 | out.append(self.TAG) 152 | 153 | octet_string_data = bytearray() 154 | octet_string_data.extend(self.security_control.to_bytes()) 155 | octet_string_data.extend(self.invocation_counter.to_bytes(4, "big")) 156 | octet_string_data.extend(self.ciphered_text) 157 | out.append(len(octet_string_data)) 158 | out.extend(octet_string_data) 159 | return bytes(out) 160 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/initiate_response.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | import attr 4 | 5 | from dlms_cosem import security 6 | from dlms_cosem.protocol.xdlms.base import AbstractXDlmsApdu 7 | from dlms_cosem.protocol.xdlms.conformance import Conformance 8 | 9 | 10 | @attr.s(auto_attribs=True) 11 | class InitiateResponse(AbstractXDlmsApdu): 12 | """ 13 | InitiateResponse ::= SEQUENCE { 14 | negotiated-quality-of-service [0] IMPLICIT Integer8 OPTIONAL, 15 | negotiated-dlms-version-number Unsigned8, 16 | negotiated-conformance Conformance, -- Shall be encoded in BER 17 | server-max-receive-pdu-size Unsigned16, 18 | vaa-name ObjectName 19 | 20 | } 21 | When using LN referencing the value if vaa-name is always 0x0007 22 | """ 23 | 24 | TAG: ClassVar[int] = 0x08 25 | 26 | negotiated_conformance: Conformance 27 | server_max_receive_pdu_size: int 28 | negotiated_dlms_version_number: int = attr.ib(default=6) # Always 6 29 | negotiated_quality_of_service: int = attr.ib(default=0) # not used in dlms. 30 | 31 | @classmethod 32 | def from_bytes(cls, source_bytes: bytes): 33 | 34 | # Since Initiate response mixes BER and A-XDR we should just "handparse" it. 35 | if not source_bytes.endswith(b"\x00\x07"): 36 | raise ValueError("vaa-name in InitateResponse is not \x00\x07") 37 | 38 | data = bytearray(source_bytes[:-2]) 39 | tag = data.pop(0) 40 | if tag != cls.TAG: 41 | raise ValueError(f"Data is not a InitiateResponse APDU, got apdu tag {tag}") 42 | 43 | use_quality_of_service = data.pop(0) 44 | if use_quality_of_service: 45 | quality_of_service = data.pop(0) 46 | else: 47 | quality_of_service = 0 48 | 49 | dlms_version = data.pop(0) 50 | 51 | conformance_tag_and_length = data[:3] 52 | if conformance_tag_and_length != b"\x5f\x1f\x04": 53 | print(conformance_tag_and_length) 54 | raise ValueError("Not correct conformance tag and length") 55 | 56 | conformance = Conformance.from_bytes(data[3:-2]) 57 | 58 | max_pdu_size = int.from_bytes(data[-2:], "big") 59 | 60 | return cls( 61 | negotiated_conformance=conformance, 62 | negotiated_dlms_version_number=dlms_version, 63 | negotiated_quality_of_service=quality_of_service, 64 | server_max_receive_pdu_size=max_pdu_size, 65 | ) 66 | 67 | def to_bytes(self) -> bytes: 68 | # quick and dirty encoding 69 | out = bytearray() 70 | out.append(self.negotiated_quality_of_service) 71 | out.append(self.negotiated_dlms_version_number) 72 | out.extend(b"\x5f\x1f\x04") 73 | out.extend(self.negotiated_conformance.to_bytes()) 74 | out.extend(self.server_max_receive_pdu_size.to_bytes(2, "big")) 75 | 76 | return b"\x08" + bytes(out) + b"\x00\x07" 77 | 78 | 79 | @attr.s(auto_attribs=True) 80 | class GlobalCipherInitiateResponse(AbstractXDlmsApdu): 81 | TAG: ClassVar[int] = 40 82 | 83 | security_control: security.SecurityControlField 84 | invocation_counter: int 85 | ciphered_text: bytes 86 | 87 | @classmethod 88 | def from_bytes(cls, source_bytes: bytes): 89 | data = bytearray(source_bytes) 90 | tag = data.pop(0) 91 | if tag != cls.TAG: 92 | raise ValueError(f"Tag is not correct. Should be {cls.TAG} but got {tag}") 93 | 94 | length = data.pop(0) 95 | if length != len(data): 96 | raise ValueError(f"Octetstring is not of correct length") 97 | 98 | security_control = security.SecurityControlField.from_bytes( 99 | data.pop(0).to_bytes(1, "big") 100 | ) 101 | invocation_counter = int.from_bytes(data[:4], "big") 102 | ciphered_text = bytes(data[4:]) 103 | 104 | return cls(security_control, invocation_counter, ciphered_text) 105 | 106 | def to_bytes(self): 107 | out = bytearray() 108 | out.append(self.TAG) 109 | 110 | octet_string_data = bytearray() 111 | octet_string_data.extend(self.security_control.to_bytes()) 112 | octet_string_data.extend(self.invocation_counter.to_bytes(4, "big")) 113 | octet_string_data.extend(self.ciphered_text) 114 | out.append(len(octet_string_data)) 115 | out.extend(octet_string_data) 116 | return bytes(out) 117 | -------------------------------------------------------------------------------- /dlms_cosem/protocol/xdlms/invoke_id_and_priority.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | import attr 4 | 5 | 6 | @attr.s(auto_attribs=True) 7 | class InvokeIdAndPriority: 8 | """ 9 | :parameter invoke_id: It is allowed to send several requests to the server (meter) 10 | if the lower layers support it, before listening for the response. To be able to 11 | correlate an answer to a request the invoke_id is used. It is copied in the 12 | response from the server. 13 | 14 | :parameter confirmed: Indicates if the service is confirmed. Mostly it is. 15 | 16 | :parameter high_priority: When sending several requests to the server (meter) it is 17 | possible to mark some of them as high priority. These response from the requests 18 | will be sent back before the ones with normal priority. Handling of priority is 19 | a negotiable feature in the Conformance block during Application Association. 20 | If the server (meter) does not support priority it will treat all requests with 21 | high priority as normal priority. 22 | 23 | """ 24 | 25 | invoke_id: int = attr.ib(default=1) 26 | confirmed: bool = attr.ib(default=True) 27 | high_priority: bool = attr.ib(default=True) 28 | 29 | LENGTH: ClassVar[int] = 1 30 | 31 | @classmethod 32 | def from_bytes(cls, source_bytes: bytes): 33 | if len(source_bytes) != cls.LENGTH: 34 | raise ValueError( 35 | f"Length of data does not correspond with class LENGTH. " 36 | f"Should be {cls.LENGTH}, got {len(source_bytes)}" 37 | ) 38 | 39 | val = int.from_bytes(source_bytes, "big") 40 | invoke_id = val & 0b00001111 41 | confirmed = bool(val & 0b01000000) 42 | high_priority = bool(val & 0b10000000) 43 | return cls( 44 | invoke_id=invoke_id, confirmed=confirmed, high_priority=high_priority 45 | ) 46 | 47 | def to_bytes(self) -> bytes: 48 | out = self.invoke_id 49 | out += self.confirmed << 6 50 | out += self.high_priority << 7 51 | return out.to_bytes(1, "big") 52 | -------------------------------------------------------------------------------- /dlms_cosem/state.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import structlog 3 | 4 | from dlms_cosem.exceptions import LocalDlmsProtocolError 5 | from dlms_cosem.protocol import acse, xdlms 6 | 7 | LOG = structlog.get_logger() 8 | 9 | 10 | class _SentinelBase(type): 11 | """ 12 | Sentinel values 13 | 14 | - Inherit identity-based comparison and hashing from object 15 | - Have a nice repr 16 | - Have a *bonus property*: type(sentinel) is sentinel 17 | 18 | The bonus property is useful if you want to take the return value from 19 | next_event() and do some sort of dispatch based on type(event). 20 | 21 | Taken from h11. 22 | """ 23 | 24 | def __repr__(self): 25 | return self.__name__ 26 | 27 | 28 | # Some simple flow control classes 29 | 30 | 31 | @attr.s() 32 | class HlsStart: 33 | pass 34 | 35 | 36 | @attr.s() 37 | class HlsSuccess: 38 | pass 39 | 40 | 41 | @attr.s() 42 | class HlsFailed: 43 | pass 44 | 45 | 46 | @attr.s() 47 | class RejectAssociation: 48 | pass 49 | 50 | 51 | @attr.s() 52 | class EndAssociation: 53 | """ 54 | Is used when settings.use_rlrq_rlre == False to send the state to NO_ASSOCIATION 55 | """ 56 | pass 57 | 58 | 59 | def make_sentinel(name): 60 | cls = _SentinelBase(name, (_SentinelBase,), {}) 61 | cls.__class__ = cls 62 | return cls 63 | 64 | 65 | NO_ASSOCIATION = make_sentinel("NO_ASSOCIATION") 66 | 67 | AWAITING_ASSOCIATION_RESPONSE = make_sentinel("AWAITING_ASSOCIATION_RESPONSE") 68 | 69 | READY = make_sentinel("READY") 70 | 71 | AWAITING_RELEASE_RESPONSE = make_sentinel("AWAITING_RELEASE_RESPONSE") 72 | AWAITING_ACTION_RESPONSE = make_sentinel("AWAITING_ACTION_RESPONSE") 73 | AWAITING_GET_RESPONSE = make_sentinel("AWAITING_GET_RESPONSE") 74 | AWAITING_GET_BLOCK_RESPONSE = make_sentinel("AWAITING_GET_BLOCK_RESPONSE") 75 | SHOULD_ACK_LAST_GET_BLOCK = make_sentinel("SHOULD_ACK_LAST_GET_BLOCK") 76 | AWAITING_SET_RESPONSE = make_sentinel("AWAITING_SET_RESPONSE") 77 | 78 | SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT = make_sentinel( 79 | "SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT" 80 | ) 81 | AWAITING_HLS_CLIENT_CHALLENGE_RESULT = make_sentinel( 82 | "AWAITING_HLS_CLIENT_CHALLENGE_RESULT" 83 | ) 84 | HLS_DONE = make_sentinel("HLS_DONE") 85 | 86 | NEED_DATA = make_sentinel("NEED_DATA") 87 | 88 | # TODO: block handling is not working with this state layout. 89 | 90 | DLMS_STATE_TRANSITIONS = { 91 | NO_ASSOCIATION: {acse.ApplicationAssociationRequest: AWAITING_ASSOCIATION_RESPONSE}, 92 | AWAITING_ASSOCIATION_RESPONSE: { 93 | acse.ApplicationAssociationResponse: READY, 94 | xdlms.ExceptionResponse: NO_ASSOCIATION, 95 | }, 96 | READY: { 97 | acse.ReleaseRequest: AWAITING_RELEASE_RESPONSE, 98 | xdlms.GetRequestNormal: AWAITING_GET_RESPONSE, 99 | xdlms.GetRequestWithList: AWAITING_GET_RESPONSE, 100 | xdlms.SetRequestNormal: AWAITING_SET_RESPONSE, 101 | HlsStart: SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT, 102 | RejectAssociation: NO_ASSOCIATION, 103 | xdlms.ActionRequestNormal: AWAITING_ACTION_RESPONSE, 104 | xdlms.DataNotification: READY, 105 | EndAssociation: NO_ASSOCIATION, 106 | }, 107 | SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT: { 108 | xdlms.ActionRequestNormal: AWAITING_HLS_CLIENT_CHALLENGE_RESULT 109 | }, 110 | AWAITING_HLS_CLIENT_CHALLENGE_RESULT: { 111 | xdlms.ActionResponseNormalWithData: HLS_DONE, 112 | xdlms.ActionResponseNormal: NO_ASSOCIATION, 113 | xdlms.ActionResponseNormalWithError: NO_ASSOCIATION, 114 | }, 115 | HLS_DONE: {HlsSuccess: READY, HlsFailed: NO_ASSOCIATION}, 116 | AWAITING_GET_RESPONSE: { 117 | xdlms.GetResponseNormal: READY, 118 | xdlms.GetResponseWithList: READY, 119 | xdlms.GetResponseWithBlock: SHOULD_ACK_LAST_GET_BLOCK, 120 | xdlms.GetResponseNormalWithError: READY, 121 | xdlms.ExceptionResponse: READY, 122 | }, 123 | AWAITING_GET_BLOCK_RESPONSE: { 124 | xdlms.GetResponseWithBlock: SHOULD_ACK_LAST_GET_BLOCK, 125 | xdlms.GetResponseNormalWithError: READY, 126 | xdlms.ExceptionResponse: READY, 127 | xdlms.GetResponseLastBlockWithError: READY, 128 | xdlms.GetResponseLastBlock: READY, 129 | }, 130 | AWAITING_SET_RESPONSE: {xdlms.SetResponseNormal: READY}, 131 | AWAITING_ACTION_RESPONSE: { 132 | xdlms.ActionResponseNormal: READY, 133 | xdlms.ActionResponseNormalWithData: READY, 134 | xdlms.ActionResponseNormalWithError: READY, 135 | }, 136 | SHOULD_ACK_LAST_GET_BLOCK: {xdlms.GetRequestNext: AWAITING_GET_BLOCK_RESPONSE}, 137 | AWAITING_RELEASE_RESPONSE: { 138 | acse.ReleaseResponse: NO_ASSOCIATION, 139 | xdlms.ExceptionResponse: READY, 140 | }, 141 | } 142 | 143 | 144 | @attr.s(auto_attribs=True) 145 | class DlmsConnectionState: 146 | """ 147 | Handles state changes in DLMS, we only focus on Client implementation as of now. 148 | 149 | A DLMS event is passed to `process_event` and it moves the state machine to the 150 | correct state. If an event is processed that is not set to be able to transition 151 | the state in the current state a LocalProtocolError is raised. 152 | """ 153 | 154 | current_state: _SentinelBase = attr.ib(default=NO_ASSOCIATION) 155 | 156 | def process_event(self, event): 157 | 158 | self._transition_state(type(event)) 159 | 160 | def _transition_state(self, event_type): 161 | try: 162 | new_state = DLMS_STATE_TRANSITIONS[self.current_state][event_type] 163 | except KeyError: 164 | raise LocalDlmsProtocolError( 165 | f"can't handle event type {event_type} when state={self.current_state}" 166 | ) 167 | old_state = self.current_state 168 | self.current_state = new_state 169 | LOG.debug(f"DLMS state transitioned", old_state=old_state, new_state=new_state) 170 | -------------------------------------------------------------------------------- /dlms_cosem/utils.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | from dlms_cosem import a_xdr, dlms_data 4 | from dlms_cosem.dlms_data import decode_variable_integer 5 | 6 | 7 | def parse_as_dlms_data(data: bytes): 8 | data_decoder = a_xdr.AXdrDecoder( 9 | encoding_conf=a_xdr.EncodingConf( 10 | attributes=[a_xdr.Sequence(attribute_name="data")] 11 | ) 12 | ) 13 | return data_decoder.decode(data)["data"] 14 | 15 | 16 | def parse_dlms_object(source_bytes: bytes) -> List[int]: 17 | """ 18 | Some DLMS object attributes contain data structures. The items are not self 19 | descriptive and we cannot use the normal parser since we dont know what items to 20 | parse as the data tag is not included. 21 | 22 | But is seems they are always integers so we can parse them as a list of integers. 23 | """ 24 | values = list() 25 | data = bytearray(source_bytes) 26 | tag = data.pop(0) 27 | allowed_dlms_object_tags = [dlms_data.DataArray.TAG, dlms_data.DataStructure.TAG] 28 | if tag not in allowed_dlms_object_tags: 29 | raise ValueError( 30 | f"You cannot use the dlms object parse " 31 | f"with {dlms_data.DlmsDataFactory.get_data_class(tag)}" 32 | ) 33 | length, rest = decode_variable_integer(data) 34 | data = rest 35 | for i in range(0, length): 36 | values.append(data.pop(0)) 37 | 38 | return values 39 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | www.dlms.dev 2 | -------------------------------------------------------------------------------- /docs/api_design.md: -------------------------------------------------------------------------------- 1 | # API Design 2 | 3 | We want a simple API for the actions you perform on objects in a DLMS meter. 4 | DLSM follows a request/response flow. 5 | 6 | Simillar to how redis can pipeline actions there is the possiblite to "pipeline" 7 | commands in DLMS with the ACCESS service where several actions can 8 | be "clumped together in on request" 9 | 10 | The client should take care of association aquire and release without the user needing 11 | to set it up. It should also be possible to set up a predefined assosiation and skip the 12 | aquire and release. 13 | 14 | ```python 15 | with Client() as client: 16 | response = client.set("1.2.3.4.5", 2, "test") 17 | ``` 18 | 19 | But this is a bit too simple since we need to know how to model the objects in the meter. 20 | 21 | There should be a way of defining the objects available in the meter beforehand or 22 | set it up as the request goes away. Then if something is wrong in the calling a proper 23 | error can be generated. 24 | 25 | ```python 26 | with Client() as client: 27 | response = client.set( 28 | logical_name=LogicalNameReference(a=1, b=2, c=3, d=4, e=5, f=5) 29 | interface=PushInterfaceV2, 30 | attribute=2, 31 | value="test" 32 | ) 33 | # above should be the same as 34 | response = client.set("1.2.3.4.5", 2, "test") 35 | # if we know that the PushInterface is on LN 1.2.3.4.5 beforehand 36 | # It could be a simple dict. 37 | 38 | # it would also make it possible to use something like: 39 | response = client["1.2.3.4.5"][0] # to initate a get in the attribute. 40 | # or 41 | response = client["1.2.3.4.5"].set(2, "test") 42 | 43 | ``` 44 | 45 | Since we want automatic handling of assiciation aquire and relase we need to set up 46 | the client with data so it can handle it for us. 47 | 48 | ```python 49 | 50 | with Client(encryption_key="0f0f", security_setup=x, proposed_conformance=Conformance() as client: 51 | response = client.set("1.2.3.4.5", 2, "test") 52 | ``` 53 | 54 | Should result in an AARQ is sent according to association. Set up dedicated key 55 | encryption, handle HLS password transfer, etc. 56 | When you then send a SET (WriteRequestApdu) it should be automatically encrypted 57 | with the global or dedicated key. If block is supported in conformance it should be 58 | split into blocks and sent. The response should be received and when it is fully 59 | received it should be decrypted and returned to the user. 60 | 61 | By having the "heavy stuff" handled in the background it makes it possbile to keep a 62 | simple API while adding incfreasing functionallity to the middlestack. 63 | 64 | We also need to add a certain transport to the client so it can send its data to the 65 | meter. HDLC and IP should be implemented. Maybe HDLC_over_IP. But since that is in the 66 | IO part it can easily be broken out and extended as needed, supporting asyncio or 67 | whatever. 68 | 69 | The main part of the protocol implementation should still be sans-io. 70 | 71 | client.set() 72 | -> SetRequest 73 | -> GlobalCipheredApdu 74 | -> Block, Block, Block 75 | -> connection.send("data") 76 | -> data is sent over wire. 77 | <- data is received. 78 | <- Block, Block, Block 79 | <- GlobalCipheredApdu 80 | <- SetResponse 81 | 82 | 83 | The client should be in the background and an abstraction should face the user 84 | (if the want to). 85 | Ex classname: Meter 86 | The Meter class holds information on all objects on the meter. By reading the 87 | object list of the an association it is possible to get a list of all objects and the 88 | access rights on each attribute and method. Even the selective access information is 89 | available. But the attribute are different for each interface type and the data you 90 | read from them or write to them are different. Many interfaces attributes have a static 91 | value. We want the opportunity to predefine the static data so that we automatically 92 | can interpret the data returned from the dynamic attributes. 93 | For example a profile generic: 94 | To be able to interpret the buffer we need to know the captured_object. 95 | To be able to interpret the values in the buffer we need to look up the object 96 | (ic=DATA just holds a value, ic=Register holds a dynamic value and static information 97 | about the scalar and the unit.) 98 | 99 | 100 | So we want a way to read the current assosiation list 101 | For every instance object we would like to read all the static information. 102 | This should then be outputted into a file. yaml for humans or json for machines. 103 | By supplying the file to the Meter class it is possible to call instances and get 104 | values back. If we don't have access to do something raise an Exception. 105 | 106 | Example file structure: 107 | ````yaml 108 | objects: 109 | 1.2.3.4.5: 110 | interface_class: 3 # Register 111 | version: 0 112 | attributes: 113 | 1: "1.2.3.4.5" # Logical Name 114 | 3: 115 | scalar: 3 116 | unit: 13 117 | 0.0.99.0.0.255: 118 | interface_class: 7 # Profile Generic 119 | version: 1 120 | attributes: 121 | 1: "0.0.99.0.0.255" # Logical Name 122 | 3: # capture objects 123 | - interface: 3 124 | instance: "1.2.3.4.5" 125 | attribute: 2 126 | data_index: 0 127 | - interface: 3 128 | instance: "2.2.3.4.5" 129 | attribute: 2 130 | data_index: 0 131 | - interface: 3 132 | instance: "3.2.3.4.5" 133 | attribute: 2 134 | data_index: 0 135 | 4: 60 # capture period 136 | 5: 1 # sort method 137 | 6: # sort object 138 | interface: 3 139 | instance: "1.2.3.4.5" 140 | attribute: 2 141 | data_index: 0 142 | 8: 30 # profile_entries 143 | selective_access: 144 | 2: 145 | - 1 146 | - 2 147 | access_rights: 148 | 1: 149 | - 1 150 | - 2 151 | - 3 152 | - 4 153 | - 5 154 | - 6 155 | - 7 156 | - 8 157 | 158 | 159 | ```` 160 | 161 | ```python 162 | meter = Meter.from_json(my_json_file) 163 | meter.get("1.2.3.4.5", 2, selective_access=make_range_descriptor()) 164 | 165 | # pipelineing access 166 | access = meter.access() 167 | access.get() 168 | access.set() 169 | access.action() 170 | access.execute() 171 | 172 | # same as 173 | access = meter.access().get().set().action().execute() 174 | 175 | load_profile = ( 176 | meter.objects.get("1.2.3.4.5", 2) 177 | .filter_range(from_value="2020-02-03", to_value="2020-03-03") 178 | .filter_columns(from_value=2, to_value=3) 179 | ) 180 | 181 | 182 | meter["1.2.3.4.5"].capture_objects 183 | 184 | ``` 185 | s 186 | -------------------------------------------------------------------------------- /docs/connect_to_your_meter.md: -------------------------------------------------------------------------------- 1 | # Connect to your meter 2 | 3 | ## Get an optical probe 4 | The simplest way to start reading data from your meter is via the optical port. 5 | You will need an optical probe to read the data, like [these ones](http://www.optical-probe.de/Optical%20probes/product.html). 6 | 7 | There are many makes of probes. Usually each meter manufacturer also sells 8 | a variant, but they can be a bit pricey, however they are usually of good quality. 9 | 10 | ## Is your meter using direct HDLC or IEC62056-21 Mode E handshake? 11 | This library, as of now, just supports the direct HDLC enabled meters. 12 | 13 | When you have a meter using IEC62045-21 you need to start with an IEC62056-21 14 | initiation sequence before you can start the HDLC session. 15 | Meters have it this way to enable users to still read the meter via the optical port 16 | using the simpler IEC62056-21 protocol. Check out our python library for 17 | [IEC62056-21](https://github.com/pwitab/iec62056-21) 18 | 19 | 20 | ## Find out how to address your meter. 21 | When using HDLC you will need to know the physical address to use in HDLC communications. 22 | This is not something that is standardized, and you might have to try different values 23 | to get it to work. 24 | 25 | Here are some values we have gathered: 26 | 27 | **Manufacturer** | **Meter** | **client physical address** | **server (meter) physical address** 28 | --- | --- | --- | --- 29 | Iskraemeco | AM550 | 1 | 17 30 | 31 | ## Is your meter protected? 32 | 33 | Most meters have at least two association options. You can connect to the public part 34 | via the `public_client` of the meter to read non sensitive data but for reading current and historical values 35 | you will need to use the `management client` 36 | 37 | These addresses are reffered as logical addresses. A meter can have several logical 38 | devices in one physical meter (mostly its just one) so the `server_logical_address` is 39 | to address which logical device in the meter you want to connect to and the 40 | `client_logical_address` shows with what kind of client privileges you want to connect 41 | with. 42 | 43 | The public client uses `client_logical_address=16` and the management client uses 44 | `client_logical_address=1`. 45 | 46 | Other addresses can be used for clients with specific privileges or for pre-established 47 | associations. 48 | This is up the meter manufacturer and/or the companion standard the meter 49 | supports to define. 50 | 51 | ### Password 52 | A meter can use no security, Low Level Security (LLS) or High Level Security for the 53 | authenticating against the meter 54 | 55 | No security means that no password needs to be submitted. 56 | 57 | Low Level Security just means a password needs to be submitted. 58 | 59 | High level security involves several passes with exchange of challenges between the 60 | client and meter and then verifying those challenges. Several methods of validating 61 | the challenge exists. 62 | 63 | * Manufacturer specific 64 | * MD5 65 | * SHA1 66 | * GMAC 67 | * SHA256 68 | * ECDSA 69 | 70 | As of now `dlms-cosem` supports HLS-GMAC 71 | 72 | ### Encryption and authentication 73 | 74 | Your meter might enforce encryption and/or authentication of messages. If you don't 75 | have the keys it will be impossible to communicate with your meter. 76 | 77 | Each encryption key also have an invocation counter. This is to protect the meter from 78 | replay attacks. After each use the invocation counter needs to be incremented. If 79 | the meter receives a message with an invocation counter that is the same or lower than 80 | in the last message it will discard the message. 81 | 82 | If you don't know the current invocation counter you can usually read it from the meter 83 | using the public client. 84 | 85 | It is also possible to sign messages and use a public key infrastructure for 86 | encryption, but it is not yet supported in `dlms-cosem` 87 | 88 | ### Security Suite 89 | A meter also usually adheres to a security suite (0-2). All this does is defining what 90 | cryptographic functions should be used for certain cryptographic operations. 91 | 92 | **Operation** | **Security Suite 0** | **Security Suite 1** | **Security Suite 2** 93 | --- | --- | --- | --- 94 | Authenticated Encryption | AES-GCM-128 | AES-GCM-128 | AES-GCM-256 95 | Key Transport | AES-GCM-128 | AES-GCM-128 | AES-GCM-256 96 | Digital Signature | NA | ECDSA with P-256 | ECDSA with P-384 97 | Key Agreement | NA | ECDSA with P-256 | ECDSA with P-384 98 | Hash | NA | SHA-256 | SHA-384 99 | Compression | NA | v.44 | v.44 100 | 101 | For now the most important take away from the security suite is to make sure you are 102 | using keys of the correct length. 103 | 104 | 105 | ## Simple example 106 | 107 | ```python3 108 | from dlms_cosem.client import DlmsClient 109 | from dlms_cosem import cosem, enumerations 110 | 111 | usb_port: str = "/dev/tty.usbserial-A704H991" 112 | 113 | # public client 114 | dlms_client = DlmsClient.with_serial_hdlc_transport(serial_port=usb_port, 115 | server_logical_address=1, 116 | server_physical_address=17, 117 | client_logical_address=16, ) 118 | 119 | # Send HDLC connection and send an ApplicationAssociationRequest (AARQ) 120 | dlms_client.associate() 121 | 122 | # read an invocation counter 123 | data: bytes = dlms_client.get( 124 | cosem.CosemAttribute(interface=enumerations.CosemInterface.DATA, 125 | instance=cosem.Obis(0, 0, 0x2B, 1, 0), attribute=2, )) 126 | 127 | # Release the association by sending a ReleaseRequest and then closing the HDLC connection 128 | dlms_client.release_association() 129 | 130 | # alternatively use the contextmanager .session() to handle the association and 131 | # connection automatically. 132 | with dlms_client.session() as client: 133 | data: bytes = client.get( 134 | cosem.CosemAttribute(interface=enumerations.CosemInterface.DATA, 135 | instance=cosem.Obis(0, 0, 0x2B, 1, 0), attribute=2, )) 136 | 137 | ``` 138 | -------------------------------------------------------------------------------- /docs/dlms_cosem.md: -------------------------------------------------------------------------------- 1 | # About DLMS/COSEM 2 | 3 | DLMS/COSEM (IEC 62056, EN13757-1) is the global standard for smart energy 4 | metering, control and management. It specifies an object-oriented data model, 5 | an application layer protocol and media-specific communication profiles. 6 | 7 | DLMS/COSEM comprises three key components: 8 | 9 | ### DLMS 10 | Device Language Message Specification - the application layer protocol 11 | that turns the information held by COSEM objects into messages. 12 | 13 | DLMS/COSEM can be used for all utilities / energy kinds, all market segments, 14 | all applications and over virtually any communication media. 15 | 16 | ### COSEM 17 | Companion Specification for Energy Metering - the object model capable of 18 | describing virtually any application. 19 | 20 | ### OBIS 21 | Object Identification System, the naming system of the objects 22 | 23 | 24 | ## COSEM (Companion Specification for Energy Metering) 25 | 26 | 27 | The COSEM object model describes the semantics of the language. 28 | 29 | COSEM interface classes and their instantiations (objects) can be readily used 30 | for modelling metering use cases, yet general enough to model any application. 31 | 32 | Object modelling is a powerful tool to formally represent simple or complex 33 | data. Each aspect of the data is modelled with an attribute. Objects may have 34 | several attributes and also methods to perform operations on the attributes. 35 | 36 | Objects can be used in combinations, to model simple use cases such as register 37 | reading or more complex ones such as tariff and billing schemes or load 38 | management. 39 | 40 | ## OBIS (Object Identification System) 41 | 42 | 43 | OBIS is the naming system of COSEM objects. 44 | 45 | OBIS codes are specified for electricity, gas, water, heat cost allocators 46 | (HCAs) and thermal energy metering, as well as for abstract data that are not 47 | related to the energy kind measured. 48 | 49 | The hierarchical structure of OBIS allows classifying the characteristics of 50 | the data e.g. electrical energy – active power – integration – tariff – 51 | billing period. 52 | 53 | 54 | ## DLMS /COSEM application layer services 55 | 56 | DLMS stands for Device Language Message Specification 57 | 58 | The syntax of the language is specified by the DLMS services. 59 | 60 | DLMS/COSEM uses a client-server model where the end devices, typically 61 | meters are the servers and the Head End Systems are the 62 | clients. 63 | 64 | The DLMS/COSEM application layer provides: 65 | 66 | * the ACSE services to connect the clients and the servers. 67 | * the xDLMS services to access the data held by the COSEM objects. The xDLMS 68 | services are the same for each object; this allows new objects to be added 69 | to the model without affecting the application layer. 70 | * The application layer also builds the messages (APDUs, Application Protocol 71 | Data Units), applies, check and removes cryptographic protection as needed 72 | and manages transferring long messages in blocks. 73 | 74 | The messages can transported over virtually any communication media. 75 | 76 | There are various built-in mechanisms available for optimizing the traffic to 77 | the characteristics of the media. 78 | 79 | ## Transport 80 | 81 | The application messages can be transported over virtually any communication 82 | media. 83 | 84 | The DLMS/COSEM communication profiles specify, for each communication the 85 | protocol stack and the binding of the lower protocol layers to the DLMS/COSEM 86 | application layer. 87 | 88 | Communication profiles are available for: 89 | 90 | * Local ports, PSTN/GSM: with HDLC data link layer RS232 / RS485; 91 | * GPRS/LTE/NB-IoT; 92 | * IPv6, IPv4, TCP and UDP; 93 | * S-FSK PLC; 94 | * G3-PLC with UDP/ IPv6; 95 | * Prime PLC without IP, with IPv6, IPv4, TCP and UDP; 96 | * Wired and wireless M-Bus; 97 | * Mesh networks with IPv6 and 6LowPAN; 98 | * Wi-SUN 99 | * LoRaWAN 100 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # DLMS.dev 2 | *Resources for the python library `dlms-cosem` and general DLMS/COSEM* 3 | 4 | 5 | ## About 6 | 7 | `dlms-cosem` is a protocol and client library for DLMS/COSEM. It is mainly used for 8 | communication with energy meters. 9 | 10 | ## Installation 11 | 12 | ``` 13 | pip install dlms-cosem 14 | ``` 15 | 16 | !!! note 17 | We only support Python 3.6+ 18 | 19 | 20 | ## Design 21 | 22 | `dlms-cosem` is designed to be a tool with a simple API for working with DLMS/COSEM 23 | enabled energy meters. It provides the lowest level function, as protocol state 24 | management, APDU encoding/decoding, APDU encryption/decryption. 25 | 26 | The library aims to provide a [sans-io](https://sans-io.readthedocs.io/) implementation 27 | of the DLMS/COSEM protocol so that the protocol code can be reused with several 28 | different io-paradigms. As of now we provide a simple client implementation based on 29 | blocking I/O. 30 | 31 | We have not implemented full support to be able to build a server (meter) emulator. If 32 | this is a use-case you need, consider sponsoring the development and contact us. 33 | 34 | ## Supported meters 35 | 36 | Technically we aim to support any DLMS enabled meter. But since the library is low 37 | level DLMS you might need an abstraction layer to support everything in your meter. 38 | 39 | DLMS/COSEM specifies many different ways of performing tasks on a meter. It is 40 | customary that a meter also adheres to a companion standard. In the companion standard 41 | it is defined exactly how certain use-cases are to be performed and how data is modeled. 42 | 43 | Examples of companion standards are: 44 | 45 | * DSMR (Netherlands) 46 | * IDIS (all Europe) 47 | * UNI/TS 11291 (Italy) 48 | 49 | On top of it all your DSO (Distribution Service Operator) might have ordered their 50 | meters with extra functionality or reduced functionality from one of the companion 51 | standards. 52 | 53 | We have some meters we have run tests on or know the library is used for in production 54 | 55 | * Pietro Fiorentini RSE 1,2 LA N1. Italian gas meter 56 | * Iskraemeco AM550. IDIS compliant electricity meter. 57 | 58 | ## Development 59 | 60 | This library is developed by Palmlund Wahlgren Innovative Technology AB. We are 61 | based in Sweden and are members of the DLMS User Association. 62 | 63 | If you find a bug pleas raise an issue on Github. 64 | 65 | We welcome contributions of any kind. 66 | 67 | We add features depending on our own use cases and our clients use cases. If you 68 | need a feature implemented please contact us. 69 | 70 | ## Training / Consultancy / Commercial Support 71 | 72 | We offer consultancy service and training services around this library and general DLMS/COSEM. 73 | If you are interested in our services just reach out to us. 74 | 75 | If you have implemented a solution based on this library we also offer a commercial 76 | support scheme. 77 | -------------------------------------------------------------------------------- /examples/associations_list.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import partial 3 | from pprint import pprint 4 | 5 | from dlms_cosem import cosem, enumerations, utils, security 6 | from dlms_cosem.client import DlmsClient 7 | from dlms_cosem.io import TcpTransport, BlockingTcpIO 8 | from dlms_cosem.parsers import AssociationObjectListParser 9 | from dlms_cosem.protocol.xdlms.conformance import Conformance 10 | 11 | # set up logging so you get a bit nicer printout of what is happening. 12 | logging.basicConfig( 13 | level=logging.DEBUG, 14 | format="%(asctime)s,%(msecs)d : %(levelname)s : %(message)s", 15 | datefmt="%H:%M:%S", 16 | ) 17 | 18 | c = Conformance( 19 | general_protection=False, 20 | general_block_transfer=False, 21 | delta_value_encoding=False, 22 | attribute_0_supported_with_set=False, 23 | priority_management_supported=False, 24 | attribute_0_supported_with_get=False, 25 | block_transfer_with_get_or_read=True, 26 | block_transfer_with_set_or_write=False, 27 | block_transfer_with_action=True, 28 | multiple_references=True, 29 | data_notification=False, 30 | access=False, 31 | get=True, 32 | set=True, 33 | selective_access=True, 34 | event_notification=False, 35 | action=True, 36 | ) 37 | 38 | encryption_key = bytes.fromhex("990EB3136F283EDB44A79F15F0BFCC21") 39 | authentication_key = bytes.fromhex("EC29E2F4BD7D697394B190827CE3DD9A") 40 | auth = enumerations.AuthenticationMechanism.HLS_GMAC 41 | serial_port = "/dev/tty.usbserial-A704H8XP" 42 | 43 | # public_client = partial( 44 | # DlmsClient.with_serial_hdlc_transport, 45 | # serial_port=serial_port, 46 | # server_logical_address=1, 47 | # server_physical_address=17, 48 | # client_logical_address=16, 49 | # ) 50 | # 51 | # management_client = partial( 52 | # DlmsClient.with_serial_hdlc_transport, 53 | # serial_port=serial_port, 54 | # server_logical_address=1, 55 | # server_physical_address=17, 56 | # client_logical_address=1, 57 | # authentication_method=auth, 58 | # encryption_key=encryption_key, 59 | # authentication_key=authentication_key, 60 | # ) 61 | 62 | LOAD_PROFILE_BUFFER = cosem.CosemAttribute( 63 | interface=enumerations.CosemInterface.PROFILE_GENERIC, 64 | instance=cosem.Obis(1, 0, 99, 1, 0), 65 | attribute=2, 66 | ) 67 | 68 | CURRENT_ASSOCIATION_OBJECTS = cosem.CosemAttribute( 69 | interface=enumerations.CosemInterface.ASSOCIATION_LN, 70 | instance=cosem.Obis(0, 0, 40, 0, 0), 71 | attribute=2, 72 | ) 73 | 74 | host = "127.0.0.1" 75 | port = 11703 76 | transport = TcpTransport( 77 | io=BlockingTcpIO(host, port), server_logical_address=1, client_logical_address=16 78 | ) 79 | 80 | with DlmsClient( 81 | transport=transport, authentication=security.NoSecurityAuthentication() 82 | ).session() as client: 83 | 84 | profile = client.get( 85 | CURRENT_ASSOCIATION_OBJECTS, 86 | ) 87 | 88 | result = utils.parse_as_dlms_data(profile) 89 | meter_objects_list = AssociationObjectListParser.parse_entries(result) 90 | meter_objects_dict = { 91 | obj.logical_name.to_string(): obj for obj in meter_objects_list 92 | } 93 | pprint(meter_objects_dict) 94 | -------------------------------------------------------------------------------- /examples/dlms_with_hdlc_example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pprint import pprint 3 | 4 | from dateutil import parser as dateparser 5 | 6 | from dlms_cosem import a_xdr, cosem, enumerations, utils 7 | from dlms_cosem.security import ( 8 | NoSecurityAuthentication, 9 | HighLevelSecurityGmacAuthentication, 10 | ) 11 | from dlms_cosem.client import DlmsClient 12 | from dlms_cosem.io import SerialIO, HdlcTransport 13 | from dlms_cosem.cosem import selective_access 14 | from dlms_cosem.cosem.selective_access import RangeDescriptor 15 | from dlms_cosem.parsers import ProfileGenericBufferParser 16 | from dlms_cosem.protocol.xdlms.conformance import Conformance 17 | 18 | # set up logging so you get a bit nicer printout of what is happening. 19 | logging.basicConfig( 20 | level=logging.DEBUG, 21 | format="%(asctime)s,%(msecs)d : %(levelname)s : %(message)s", 22 | datefmt="%H:%M:%S", 23 | ) 24 | 25 | c = Conformance( 26 | general_protection=False, 27 | general_block_transfer=False, 28 | delta_value_encoding=False, 29 | attribute_0_supported_with_set=False, 30 | priority_management_supported=False, 31 | attribute_0_supported_with_get=False, 32 | block_transfer_with_get_or_read=True, 33 | block_transfer_with_set_or_write=False, 34 | block_transfer_with_action=True, 35 | multiple_references=True, 36 | data_notification=False, 37 | access=False, 38 | get=True, 39 | set=True, 40 | selective_access=True, 41 | event_notification=False, 42 | action=True, 43 | ) 44 | 45 | encryption_key = bytes.fromhex("990EB3136F283EDB44A79F15F0BFCC21") 46 | authentication_key = bytes.fromhex("EC29E2F4BD7D697394B190827CE3DD9A") 47 | encryption_key = bytes.fromhex("FFBDED4154787C951BDA91411D4CCB26") 48 | authentication_key = bytes.fromhex("C7DDFC7EE8E0EF95B8D154C1CA09B450") 49 | 50 | 51 | auth = enumerations.AuthenticationMechanism.HLS_GMAC 52 | port = "/dev/tty.usbserial-A9031O5M" 53 | 54 | serial_io = SerialIO(port_name=port, baud_rate=9600) 55 | public_hdlc_transport = HdlcTransport( 56 | client_logical_address=16, 57 | server_logical_address=1, 58 | server_physical_address=17, 59 | io=serial_io, 60 | ) 61 | public_client = DlmsClient( 62 | transport=public_hdlc_transport, authentication=NoSecurityAuthentication() 63 | ) 64 | 65 | 66 | with public_client.session() as client: 67 | 68 | response_data = client.get( 69 | cosem.CosemAttribute( 70 | interface=enumerations.CosemInterface.DATA, 71 | instance=cosem.Obis(0, 0, 0x2B, 1, 0), 72 | attribute=2, 73 | ) 74 | ) 75 | data_decoder = a_xdr.AXdrDecoder( 76 | encoding_conf=a_xdr.EncodingConf( 77 | attributes=[a_xdr.Sequence(attribute_name="data")] 78 | ) 79 | ) 80 | invocation_counter = data_decoder.decode(response_data)["data"] 81 | print(f"meter_initial_invocation_counter = {invocation_counter}") 82 | 83 | 84 | LOAD_PROFILE_BUFFER = cosem.CosemAttribute( 85 | interface=enumerations.CosemInterface.PROFILE_GENERIC, 86 | instance=cosem.Obis(1, 0, 99, 1, 0), 87 | attribute=2, 88 | ) 89 | 90 | CURRENT_ASSOCIATION_OBJECTS = cosem.CosemAttribute( 91 | interface=enumerations.CosemInterface.ASSOCIATION_LN, 92 | instance=cosem.Obis(0, 0, 40, 0, 0), 93 | attribute=2, 94 | ) 95 | 96 | GSM_CONNECTION_INFO = cosem.CosemAttribute( 97 | interface=enumerations.CosemInterface.GPRS_MODEM_SETUP, 98 | instance=cosem.Obis(0, 0, 25, 4, 0), 99 | attribute=2, 100 | ) 101 | 102 | 103 | CLOCK_OBJECT = cosem.CosemAttribute( 104 | interface=enumerations.CosemInterface.CLOCK, 105 | instance=cosem.Obis(0, 0, 1, 0, 0, 255), 106 | attribute=2, 107 | ) 108 | 109 | 110 | LTE_SETTINGS = cosem.CosemAttribute( 111 | interface=enumerations.CosemInterface.GSM_DIAGNOSTICS, 112 | instance=cosem.Obis(0, 0, 25, 6, 0), 113 | attribute=3, 114 | ) 115 | 116 | 117 | management_hdlc_transport = HdlcTransport( 118 | client_logical_address=1, 119 | server_logical_address=1, 120 | server_physical_address=17, 121 | io=serial_io, 122 | ) 123 | management_client = DlmsClient( 124 | transport=management_hdlc_transport, 125 | authentication=HighLevelSecurityGmacAuthentication(challenge_length=32), 126 | encryption_key=encryption_key, 127 | authentication_key=authentication_key, 128 | client_initial_invocation_counter=invocation_counter + 1, 129 | ) 130 | 131 | 132 | with management_client.session() as client: 133 | 134 | profile = client.get( 135 | LOAD_PROFILE_BUFFER, 136 | access_descriptor=RangeDescriptor( 137 | restricting_object=selective_access.CaptureObject( 138 | cosem_attribute=cosem.CosemAttribute( 139 | interface=enumerations.CosemInterface.CLOCK, 140 | instance=cosem.Obis.from_string("0.0.1.0.0.255"), 141 | attribute=2, 142 | ), 143 | data_index=0, 144 | ), 145 | from_value=dateparser.parse("2022-10-12T00:00:00-01:00"), 146 | to_value=dateparser.parse("2022-10-13T00:00:00-01:00"), 147 | ), 148 | ) 149 | 150 | parser = ProfileGenericBufferParser( 151 | capture_objects=[ 152 | cosem.CosemAttribute( 153 | interface=enumerations.CosemInterface.CLOCK, 154 | instance=cosem.Obis(0, 0, 1, 0, 0, 255), 155 | attribute=2, 156 | ), 157 | cosem.CosemAttribute( 158 | interface=enumerations.CosemInterface.DATA, 159 | instance=cosem.Obis(0, 0, 96, 10, 1, 255), 160 | attribute=2, 161 | ), 162 | cosem.CosemAttribute( 163 | interface=enumerations.CosemInterface.REGISTER, 164 | instance=cosem.Obis(1, 0, 1, 8, 0, 255), 165 | attribute=2, 166 | ), 167 | cosem.CosemAttribute( 168 | interface=enumerations.CosemInterface.REGISTER, 169 | instance=cosem.Obis(1, 0, 2, 8, 0, 255), 170 | attribute=2, 171 | ), 172 | ], 173 | capture_period=60, 174 | ) 175 | 176 | result = utils.parse_as_dlms_data(profile) 177 | pprint(profile) 178 | pprint(result) 179 | -------------------------------------------------------------------------------- /examples/dlms_with_tcp_example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pprint import pprint 3 | from time import sleep 4 | 5 | from dateutil import parser as dateparser 6 | 7 | from dlms_cosem import a_xdr, cosem, enumerations 8 | from dlms_cosem.security import ( 9 | NoSecurityAuthentication, 10 | HighLevelSecurityGmacAuthentication, 11 | ) 12 | from dlms_cosem.client import DlmsClient 13 | from dlms_cosem.io import BlockingTcpIO, TcpTransport 14 | from dlms_cosem.cosem import selective_access 15 | from dlms_cosem.cosem.selective_access import RangeDescriptor 16 | from dlms_cosem.parsers import ProfileGenericBufferParser 17 | from dlms_cosem.protocol.xdlms.conformance import Conformance 18 | 19 | # set up logging so you get a bit nicer printout of what is happening. 20 | logging.basicConfig( 21 | level=logging.DEBUG, 22 | format="%(asctime)s,%(msecs)d : %(levelname)s : %(message)s", 23 | datefmt="%H:%M:%S", 24 | ) 25 | 26 | c = Conformance( 27 | general_protection=False, 28 | general_block_transfer=False, 29 | delta_value_encoding=False, 30 | attribute_0_supported_with_set=False, 31 | priority_management_supported=False, 32 | attribute_0_supported_with_get=False, 33 | block_transfer_with_get_or_read=True, 34 | block_transfer_with_set_or_write=False, 35 | block_transfer_with_action=True, 36 | multiple_references=True, 37 | data_notification=False, 38 | access=False, 39 | get=True, 40 | set=True, 41 | selective_access=True, 42 | event_notification=False, 43 | action=True, 44 | ) 45 | 46 | encryption_key = bytes.fromhex("990EB3136F283EDB44A79F15F0BFCC21") 47 | authentication_key = bytes.fromhex("EC29E2F4BD7D697394B190827CE3DD9A") 48 | auth = enumerations.AuthenticationMechanism.HLS_GMAC 49 | host = "100.119.108.3" 50 | port = 4059 51 | 52 | 53 | tcp_io = BlockingTcpIO(host=host, port=port) 54 | public_tcp_transport = TcpTransport( 55 | client_logical_address=16, 56 | server_logical_address=1, 57 | io=tcp_io, 58 | ) 59 | public_client = DlmsClient( 60 | transport=public_tcp_transport, authentication=NoSecurityAuthentication() 61 | ) 62 | 63 | 64 | with public_client.session() as client: 65 | 66 | response_data = client.get( 67 | cosem.CosemAttribute( 68 | interface=enumerations.CosemInterface.DATA, 69 | instance=cosem.Obis(0, 0, 0x2B, 1, 0), 70 | attribute=2, 71 | ) 72 | ) 73 | data_decoder = a_xdr.AXdrDecoder( 74 | encoding_conf=a_xdr.EncodingConf( 75 | attributes=[a_xdr.Sequence(attribute_name="data")] 76 | ) 77 | ) 78 | invocation_counter = data_decoder.decode(response_data)["data"] 79 | print(f"meter_initial_invocation_counter = {invocation_counter}") 80 | 81 | # we are not reusing the socket as of now. We just need to give the meter some time to 82 | # close the connection on its side 83 | sleep(2) 84 | 85 | tcp_io = BlockingTcpIO(host=host, port=port) 86 | management_tcp_transport = TcpTransport( 87 | client_logical_address=1, 88 | server_logical_address=1, 89 | io=tcp_io, 90 | ) 91 | 92 | management_client = DlmsClient( 93 | transport=management_tcp_transport, 94 | authentication=HighLevelSecurityGmacAuthentication(challenge_length=32), 95 | encryption_key=encryption_key, 96 | authentication_key=authentication_key, 97 | client_initial_invocation_counter=invocation_counter + 1, 98 | ) 99 | 100 | 101 | with management_client.session() as client: 102 | 103 | profile = client.get( 104 | cosem.CosemAttribute( 105 | interface=enumerations.CosemInterface.PROFILE_GENERIC, 106 | instance=cosem.Obis(1, 0, 99, 1, 0), 107 | attribute=2, 108 | ), 109 | access_descriptor=RangeDescriptor( 110 | restricting_object=selective_access.CaptureObject( 111 | cosem_attribute=cosem.CosemAttribute( 112 | interface=enumerations.CosemInterface.CLOCK, 113 | instance=cosem.Obis.from_string("0.0.1.0.0.255"), 114 | attribute=2, 115 | ), 116 | data_index=0, 117 | ), 118 | from_value=dateparser.parse("2022-01-01T00:00:00-02:00"), 119 | to_value=dateparser.parse("2022-01-02T00:00:00-01:00"), 120 | ), 121 | ) 122 | 123 | parser = ProfileGenericBufferParser( 124 | capture_objects=[ 125 | cosem.CosemAttribute( 126 | interface=enumerations.CosemInterface.CLOCK, 127 | instance=cosem.Obis(0, 0, 1, 0, 0, 255), 128 | attribute=2, 129 | ), 130 | cosem.CosemAttribute( 131 | interface=enumerations.CosemInterface.DATA, 132 | instance=cosem.Obis(0, 0, 96, 10, 1, 255), 133 | attribute=2, 134 | ), 135 | cosem.CosemAttribute( 136 | interface=enumerations.CosemInterface.REGISTER, 137 | instance=cosem.Obis(1, 0, 1, 8, 0, 255), 138 | attribute=2, 139 | ), 140 | cosem.CosemAttribute( 141 | interface=enumerations.CosemInterface.REGISTER, 142 | instance=cosem.Obis(1, 0, 2, 8, 0, 255), 143 | attribute=2, 144 | ), 145 | ], 146 | capture_period=60, 147 | ) 148 | result = parser.parse_bytes(profile) 149 | pprint(result) 150 | -------------------------------------------------------------------------------- /examples/parse_norwegian_han.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | from dlms_cosem.cosem import Obis 4 | from dlms_cosem.hdlc import frames 5 | from dlms_cosem.protocol import xdlms 6 | from dlms_cosem.time import datetime_from_bytes 7 | from dlms_cosem.utils import parse_as_dlms_data 8 | 9 | # 3-phase 10 | hdlc_data_hex = ( 11 | "7ea2434108831385ebe6e7000f4000000000011b020209060000010000ff090c07e30c1001073b28ff" 12 | "8000ff020309060100010700ff060000046202020f00161b020309060100020700ff06000000000202" 13 | "0f00161b020309060100030700ff06000005e302020f00161d020309060100040700ff060000000002" 14 | "020f00161d0203090601001f0700ff10000002020fff1621020309060100330700ff10004b02020fff" 15 | "1621020309060100470700ff10000002020fff1621020309060100200700ff12090302020fff162302" 16 | "0309060100340700ff1209c302020fff1623020309060100480700ff12090402020fff162302030906" 17 | "0100150700ff060000000002020f00161b020309060100160700ff060000000002020f00161b020309" 18 | "060100170700ff060000000002020f00161d020309060100180700ff060000000002020f00161d0203" 19 | "09060100290700ff060000046202020f00161b0203090601002a0700ff060000000002020f00161b02" 20 | "03090601002b0700ff06000005e202020f00161d0203090601002c0700ff060000000002020f00161d" 21 | "0203090601003d0700ff060000000002020f00161b0203090601003e0700ff060000000002020f0016" 22 | "1b0203090601003f0700ff060000000002020f00161d020309060100400700ff060000000002020f00" 23 | "161d020309060100010800ff060099598602020f00161e020309060100020800ff060000000802020f" 24 | "00161e020309060100030800ff060064ed4b02020f001620020309060100040800ff06000000050202" 25 | "0f001620be407e" 26 | ) 27 | 28 | hdlc_data_hex = "7EA0E22B2113239AE6E7000F000000000C07E6011801123A32FF80000002190A0E4B616D73747275705F563030303109060101000005FF0A103537303635363733323635393034303709060101600101FF0A1236383431313338424E32343531303130393009060101010700FF060000033A09060101020700FF060000000009060101030700FF060000006809060101040700FF06000000B0090601011F0700FF06000000ED09060101330700FF060000005909060101470700FF060000004B09060101200700FF1200E809060101340700FF1200E909060101480700FF1200EC84467E" 29 | 30 | ui = frames.UnnumberedInformationFrame.from_bytes(bytes.fromhex(hdlc_data_hex)) 31 | dn = xdlms.DataNotification.from_bytes( 32 | ui.payload[3:] 33 | ) # The first 3 bytes should be ignored. 34 | result = parse_as_dlms_data(dn.body) 35 | # pprint.pprint(result) 36 | # First is date 37 | # date_row = result.pop(0) 38 | # clock_obis = Obis.from_bytes(date_row[0]) 39 | # clock, stats = datetime_from_bytes(date_row[1]) 40 | # print(f"Clock object: {clock_obis.to_string()}, datetime={clock}") 41 | 42 | # rest is data 43 | for item in result: 44 | try: 45 | obis = Obis.from_bytes(item) 46 | print(obis) 47 | except Exception: 48 | print(item) 49 | 50 | clock, status = datetime_from_bytes(b"5706567326590407") 51 | print(clock) 52 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: DLMS.dev 2 | site_description: 'Resources for the python library `dlms-cosem` and general DLMS/COSEM' 3 | site_author: 'Henrik Palmlund Wahlgren @ Utilitarian' 4 | 5 | nav: 6 | - Home: index.md 7 | - About DLMS/COSEM: dlms_cosem.md 8 | - Getting Started: 9 | - Connect to your meter: connect_to_your_meter.md 10 | 11 | 12 | theme: 13 | name: material 14 | 15 | 16 | copyright: 'Copyright © 2020-2021 Palmlund Wahlgren Innovative Technology AB' 17 | 18 | repo_url: https://github.com/pwitab/dlms-cosem 19 | repo_name: pwitab/dlms-cosem 20 | 21 | 22 | markdown_extensions: 23 | - pymdownx.highlight 24 | - pymdownx.superfences 25 | - admonition 26 | - codehilite: 27 | guess_lang: false 28 | - toc: 29 | permalink: '#' 30 | - def_list 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | py36 = true 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | /( 6 | \.git 7 | | \.hg 8 | | \.mypy_cache 9 | | \.tox 10 | | \.venv 11 | | _build 12 | | buck-out 13 | | build 14 | | dist 15 | 16 | # The following are specific to Black, you probably don't want those. 17 | | blib2to3 18 | | tests/data 19 | )/ 20 | ''' 21 | 22 | [tool.isort] 23 | profile = "black" 24 | multi_line_output = 3 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from shutil import rmtree 4 | 5 | from setuptools import Command, find_packages, setup 6 | 7 | # Package meta-data. 8 | NAME = "dlms-cosem" 9 | DESCRIPTION = "A Python library for DLMS/COSEM" 10 | URL = "https://github.com/pwitab/dlms-cosem" 11 | PROJECT_URLS = { 12 | "Documentation": "https://www.dlms.dev/", 13 | "Bug Tracker": "https://github.com/pwitab/dlms-cosem/issues", 14 | "Source Code": "https://github.com/pwitab/dlms-cosem", 15 | } 16 | EMAIL = "henrik@pwit.se" 17 | AUTHOR = "Henrik Palmlund Wahlgren @ Palmlund Wahlgren Innovative Technology AB" 18 | REQUIRES_PYTHON = "~=3.7" 19 | VERSION = "24.1.0" 20 | 21 | # What packages are required for this module to be executed? 22 | REQUIRED = [ 23 | "attrs>=22.2.0", 24 | "pyserial>=3.5", 25 | "cryptography>=35.0.0", 26 | "asn1crypto>=1.4.0", 27 | "python-dateutil>=2.8.1", 28 | "typing-extensions>=3.10", 29 | "structlog>=22.1.0", 30 | ] 31 | 32 | DOC_PACKAGES = ["mkdocs", "mkdocs-material"] 33 | TEST_PACKAGES = ["pytest", "pytest-cov", "pytest-sugar"] 34 | DEV_PACKAGES = ["pre-commit"] + DOC_PACKAGES + TEST_PACKAGES 35 | 36 | EXTRAS = { 37 | "docs": DOC_PACKAGES, 38 | "test": TEST_PACKAGES, 39 | "dev": DEV_PACKAGES, 40 | } 41 | 42 | CLASSIFIERS = [ 43 | "Intended Audience :: Developers", 44 | "Natural Language :: English", 45 | "License :: Other/Proprietary License", 46 | "Operating System :: OS Independent", 47 | "Programming Language :: Python :: 3", 48 | "Programming Language :: Python :: 3 :: Only", 49 | "Programming Language :: Python :: 3.7", 50 | "Programming Language :: Python :: 3.8", 51 | "Programming Language :: Python :: 3.9", 52 | "Programming Language :: Python :: 3.10", 53 | "Programming Language :: Python :: Implementation :: CPython", 54 | "Topic :: Software Development :: Libraries :: Python Modules", 55 | "Topic :: Communications", 56 | 57 | ] 58 | 59 | here = os.path.abspath(os.path.dirname(__file__)) 60 | 61 | 62 | class UploadCommand(Command): 63 | """Support setup.py upload.""" 64 | 65 | description = "Build and publish the package." 66 | user_options = [] 67 | 68 | @staticmethod 69 | def status(s): 70 | """Prints things in bold.""" 71 | print("\033[1m{0}\033[0m".format(s)) 72 | 73 | def initialize_options(self): 74 | pass 75 | 76 | def finalize_options(self): 77 | pass 78 | 79 | def run(self): 80 | try: 81 | self.status("Removing previous builds…") 82 | rmtree(os.path.join(here, "dist")) 83 | except OSError: 84 | pass 85 | 86 | self.status("Building Source and Wheel (universal) distribution…") 87 | os.system("{0} setup.py sdist bdist_wheel".format(sys.executable)) 88 | 89 | self.status("Uploading the package to PyPI via Twine…") 90 | os.system("twine upload dist/*") 91 | 92 | self.status("Pushing git tags…") 93 | # os.system('git tag v{0}'.format(about['__version__'])) 94 | os.system("git push --tags") 95 | 96 | sys.exit() 97 | 98 | 99 | with open("README.md") as readme_file: 100 | readme = readme_file.read() 101 | 102 | with open("HISTORY.md") as history_file: 103 | history = history_file.read() 104 | 105 | setup( 106 | name=NAME, 107 | version=VERSION, 108 | python_requires=REQUIRES_PYTHON, 109 | description=DESCRIPTION, 110 | long_description=readme + "\n\n" + history, 111 | long_description_content_type="text/markdown", 112 | author=AUTHOR, 113 | author_email=EMAIL, 114 | maintainer=AUTHOR, 115 | maintainer_email=EMAIL, 116 | url=URL, 117 | project_urls=PROJECT_URLS, 118 | packages=find_packages(exclude=("tests",)), 119 | entry_points={}, 120 | install_requires=REQUIRED, 121 | extras_require=EXTRAS, 122 | include_package_data=True, 123 | license="MIT", 124 | zip_safe=False, 125 | keywords="AMR, Metering, smart meters, MDM, dlms, cosem", 126 | classifiers=CLASSIFIERS, 127 | # $ setup.py publish support. 128 | cmdclass={"upload": UploadCommand}, 129 | ) 130 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_asce/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/tests/test_asce/__init__.py -------------------------------------------------------------------------------- /tests/test_asce/test_conformance.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dlms_cosem.protocol.xdlms import Conformance 4 | 5 | # Example code found encoding found in DLMS Green Book v10 page:437 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "conformance,encoded", 10 | [ 11 | ( 12 | Conformance( 13 | priority_management_supported=True, 14 | attribute_0_supported_with_get=True, 15 | block_transfer_with_action=True, 16 | block_transfer_with_get_or_read=True, 17 | block_transfer_with_set_or_write=True, 18 | multiple_references=True, 19 | get=True, 20 | set=True, 21 | selective_access=True, 22 | event_notification=True, 23 | action=True, 24 | ), 25 | b"\x00\x00\x7e\x1f", 26 | ), 27 | ( 28 | Conformance( 29 | priority_management_supported=True, 30 | block_transfer_with_get_or_read=True, 31 | get=True, 32 | set=True, 33 | selective_access=True, 34 | event_notification=True, 35 | action=True, 36 | ), 37 | b"\x00\x00\x50\x1f", 38 | ), 39 | ], 40 | ) 41 | def test_conformance(conformance: Conformance, encoded: bytes): 42 | assert conformance.to_bytes() == encoded 43 | assert Conformance.from_bytes(encoded) == conformance 44 | -------------------------------------------------------------------------------- /tests/test_asce/test_rlre.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dlms_cosem import enumerations 4 | from dlms_cosem.protocol import xdlms 5 | from dlms_cosem.protocol.acse import ReleaseResponse 6 | 7 | 8 | class TestDecodeRLRE: 9 | def test_simple(self): 10 | data = b"c\x03\x80\x01\x00" 11 | rlre = ReleaseResponse.from_bytes(data) 12 | assert rlre.reason == enumerations.ReleaseResponseReason.NORMAL 13 | assert rlre.user_information is None 14 | 15 | def test_with_initiate_response(self): 16 | data = b"c\x16\x80\x01\x00\xbe\x11\x04\x0f\x08\x01\x00\x06_\x1f\x04\x00\x00\x1e\x1d\x04\xc8\x00\x07" 17 | rlre = ReleaseResponse.from_bytes(data) 18 | assert rlre.reason == enumerations.ReleaseResponseReason.NORMAL 19 | assert isinstance(rlre.user_information.content, xdlms.InitiateResponse) 20 | 21 | def test_with_ciphered_initiate_response(self): 22 | data = bytes.fromhex( 23 | "6328800100BE230421281F3001234567891214A0845E475714383F65BC19745CA235906525E4F3E1C893" 24 | ) 25 | rlre = ReleaseResponse.from_bytes(data) 26 | assert rlre.reason == enumerations.ReleaseResponseReason.NORMAL 27 | assert isinstance( 28 | rlre.user_information.content, xdlms.GlobalCipherInitiateResponse 29 | ) 30 | -------------------------------------------------------------------------------- /tests/test_asce/test_rlrq.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dlms_cosem import enumerations 4 | from dlms_cosem.protocol.acse import ReleaseRequest 5 | 6 | 7 | class TestDecodeRLRQ: 8 | def test_simple(self): 9 | data = bytes.fromhex("6203800100") # Normal no user-information 10 | rlrq = ReleaseRequest.from_bytes(data) 11 | assert rlrq.reason == enumerations.ReleaseRequestReason.NORMAL 12 | assert rlrq.user_information is None 13 | assert data == rlrq.to_bytes() 14 | 15 | def test_with_ciphered_initiate_request(self): 16 | data = bytes.fromhex( 17 | "6239800100be34043221303001234567801302FF8A7874133D414CED25B42534D28DB0047720606B175BD52211BE6841DB204D39EE6FDB8E356855" 18 | ) 19 | # No support for ciphnered adpus yet 20 | 21 | rlrq = ReleaseRequest.from_bytes(data) 22 | assert rlrq.reason == enumerations.ReleaseRequestReason.NORMAL 23 | -------------------------------------------------------------------------------- /tests/test_blocking_tcp_transport.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | import pytest 4 | 5 | from dlms_cosem.io import BlockingTcpIO, TcpTransport 6 | from dlms_cosem.exceptions import CommunicationError 7 | 8 | 9 | class TestBlockingTcpTransport: 10 | 11 | host = "localhost" 12 | port = 10000 13 | client_logical_address = 1 14 | server_logical_address = 1 15 | 16 | def test_can_connect(self): 17 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 18 | server_socket.bind((self.host, self.port)) 19 | server_socket.listen(1) 20 | io = BlockingTcpIO(host=self.host, port=self.port) 21 | transport = TcpTransport( 22 | self.client_logical_address, self.server_logical_address, io 23 | ) 24 | transport.connect() 25 | assert transport.io.tcp_socket 26 | 27 | def test_connect_on_connected_raises(self): 28 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 29 | server_socket.bind((self.host, self.port)) 30 | server_socket.listen(1) 31 | 32 | io = BlockingTcpIO(host=self.host, port=self.port) 33 | transport = TcpTransport( 34 | self.client_logical_address, self.server_logical_address, io 35 | ) 36 | transport.connect() 37 | with pytest.raises(RuntimeError): 38 | transport.connect() 39 | 40 | def test_cant_connect_raises_communications_error(self): 41 | io = BlockingTcpIO(host=self.host, port=self.port) 42 | transport = TcpTransport( 43 | self.client_logical_address, self.server_logical_address, io 44 | ) 45 | with pytest.raises(CommunicationError): 46 | transport.connect() 47 | 48 | def test_disconnect(self): 49 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 50 | server_socket.bind((self.host, self.port)) 51 | server_socket.listen(1) 52 | 53 | io = BlockingTcpIO(host=self.host, port=self.port) 54 | transport = TcpTransport( 55 | self.client_logical_address, self.server_logical_address, io 56 | ) 57 | transport.connect() 58 | transport.disconnect() 59 | assert transport.io.tcp_socket is None 60 | 61 | def test_disconnect_is_noop_if_disconnected(self): 62 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 63 | server_socket.bind((self.host, self.port)) 64 | server_socket.listen(1) 65 | 66 | io = BlockingTcpIO(host=self.host, port=self.port) 67 | transport = TcpTransport( 68 | self.client_logical_address, self.server_logical_address, io 69 | ) 70 | transport.connect() 71 | transport.disconnect() 72 | transport.disconnect() 73 | assert transport.io.tcp_socket is None 74 | -------------------------------------------------------------------------------- /tests/test_clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/tests/test_clients/__init__.py -------------------------------------------------------------------------------- /tests/test_clients/test_dlms_client.py: -------------------------------------------------------------------------------- 1 | from dlms_cosem.connection import DlmsConnectionSettings 2 | from dlms_cosem.security import NoSecurityAuthentication 3 | from dlms_cosem.client import DlmsClient 4 | from dlms_cosem.io import BlockingTcpIO, TcpTransport 5 | 6 | 7 | class TestDlmsClient: 8 | def test_client_invocation_counter_property(self): 9 | transport = TcpTransport( 10 | io=BlockingTcpIO(host="localhost", port=4059), 11 | client_logical_address=1, 12 | server_logical_address=1, 13 | ) 14 | client = DlmsClient( 15 | client_initial_invocation_counter=500, 16 | transport=transport, 17 | authentication=NoSecurityAuthentication(), 18 | ) 19 | 20 | assert client.client_invocation_counter == 500 21 | 22 | def test_client_invocation_counter_setter(self): 23 | transport = TcpTransport( 24 | io=BlockingTcpIO(host="localhost", port=4059), 25 | client_logical_address=1, 26 | server_logical_address=1, 27 | ) 28 | client = DlmsClient( 29 | client_initial_invocation_counter=500, 30 | transport=transport, 31 | authentication=NoSecurityAuthentication(), 32 | ) 33 | client.client_invocation_counter = 1000 34 | assert client.client_invocation_counter == 1000 35 | assert client.dlms_connection.client_invocation_counter == 1000 36 | 37 | 38 | class TestDlmsClientWithConnectionSettings: 39 | 40 | def test_can_get_settings_from_client(self): 41 | settings = DlmsConnectionSettings(empty_system_title_in_general_glo_ciphering=True) 42 | 43 | transport = TcpTransport( 44 | io=BlockingTcpIO(host="localhost", port=4059), 45 | client_logical_address=1, 46 | server_logical_address=1, 47 | ) 48 | 49 | 50 | client = DlmsClient( 51 | client_initial_invocation_counter=500, 52 | transport=transport, 53 | authentication=NoSecurityAuthentication(), 54 | connection_settings=settings 55 | ) 56 | 57 | assert client.dlms_connection.settings.empty_system_title_in_general_glo_ciphering == True 58 | -------------------------------------------------------------------------------- /tests/test_cosem.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dlms_cosem import cosem 4 | 5 | 6 | class TestObis: 7 | def test_obis_to_dotted(self): 8 | obis = cosem.Obis(1, 0, 1, 8, 0, 255) 9 | assert obis.to_string(separator=".") == "1.0.1.8.0.255" 10 | 11 | def test_obis_from_bytes(self): 12 | data = b"\x00\x00+\x01\x00\xff" 13 | assert cosem.Obis.from_bytes(data) == cosem.Obis(0, 0, 43, 1, 0, 255) 14 | 15 | def test_obis_to_bytes(self): 16 | data = b"\x00\x00+\x01\x00\xff" 17 | assert cosem.Obis(0, 0, 43, 1, 0, 255).to_bytes() == data 18 | 19 | @pytest.mark.parametrize( 20 | "test_input,expected", 21 | [ 22 | ("1-0:1.8.0.255", cosem.Obis(1, 0, 1, 8, 0, 255)), 23 | ("1-0:1.8.0", cosem.Obis(1, 0, 1, 8, 0, 255)), 24 | ("1-0-1-8-0-255", cosem.Obis(1, 0, 1, 8, 0, 255)), 25 | ("1-0-1-8-0", cosem.Obis(1, 0, 1, 8, 0, 255)), 26 | ("1.0.1.8.0.255", cosem.Obis(1, 0, 1, 8, 0, 255)), 27 | ("1.0.1.8.0", cosem.Obis(1, 0, 1, 8, 0, 255)), 28 | ], 29 | ) 30 | def test_obis_from_string(self, test_input: str, expected: cosem.Obis): 31 | assert cosem.Obis.from_string(test_input) == expected 32 | 33 | def test_to_string(self): 34 | assert cosem.Obis.from_string("1-0:1.8.0.255").to_string() == "1-0:1.8.0.255" 35 | 36 | def test_non_parsable_raises_value_error(self): 37 | with pytest.raises(ValueError): 38 | cosem.Obis.from_string("1.8.0") 39 | -------------------------------------------------------------------------------- /tests/test_dlms_data.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dlms_cosem.utils import parse_as_dlms_data 4 | from dlms_cosem import dlms_data 5 | 6 | def test_parse_data_from_kamstrup_han_port(): 7 | data = b"\x02\x19\n\x0eKamstrup_V0001\t\x06\x01\x01\x00\x00\x05\xff\n\x105706567196382485\t\x06\x01\x01`\x01\x01\xff\n\x126841131BN143101090\t\x06\x01\x01\x01\x07\x00\xff\x06\x00\x00\t&\t\x06\x01\x01\x02\x07\x00\xff\x06\x00\x00\x00\x00\t\x06\x01\x01\x03\x07\x00\xff\x06\x00\x00\x00\x00\t\x06\x01\x01\x04\x07\x00\xff\x06\x00\x00\x00\xdf\t\x06\x01\x01\x1f\x07\x00\xff\x06\x00\x00\x00\\\t\x06\x01\x013\x07\x00\xff\x06\x00\x00\x00\x8d\t\x06\x01\x01G\x07\x00\xff\x06\x00\x00\x03r\t\x06\x01\x01 \x07\x00\xff\x12\x00\xe6\t\x06\x01\x014\x07\x00\xff\x12\x00\xe6\t\x06\x01\x01H\x07\x00\xff\x12\x00\xe4" 8 | 9 | parsed = parse_as_dlms_data(data) 10 | 11 | assert len(parsed) == 25 12 | 13 | 14 | class TestVisibleString: 15 | 16 | parameter_data = [ 17 | (b"\n\x0eKamstrup_V0001", "Kamstrup_V0001"), 18 | (b"\n\x105706567196382485", "5706567196382485"), 19 | (b"\n\x126841131BN143101090", "6841131BN143101090"), 20 | ] 21 | 22 | @pytest.mark.parametrize("encoded,decoded", parameter_data) 23 | def test_parse_data(self, encoded, decoded): 24 | parsed = parse_as_dlms_data(encoded) 25 | assert parsed == decoded 26 | 27 | @pytest.mark.parametrize("encoded,decoded", parameter_data) 28 | def test_encode_data(self, encoded, decoded): 29 | obj = dlms_data.VisibleStringData(value=decoded) 30 | 31 | assert obj.to_bytes() == encoded 32 | -------------------------------------------------------------------------------- /tests/test_dlms_state.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dlms_cosem import enumerations, state 4 | from dlms_cosem.exceptions import LocalDlmsProtocolError 5 | from dlms_cosem.protocol import acse 6 | from dlms_cosem.protocol.acse import UserInformation 7 | from dlms_cosem.protocol.xdlms import Conformance, InitiateRequest 8 | 9 | 10 | def test_non_aarq_on_initial_raises_protocol_error(): 11 | s = state.DlmsConnectionState() 12 | 13 | with pytest.raises(LocalDlmsProtocolError): 14 | s.process_event(acse.ReleaseResponse()) 15 | 16 | 17 | def test_aarq_makes_dlms_waiting_for_aare(): 18 | s = state.DlmsConnectionState() 19 | s.process_event( 20 | acse.ApplicationAssociationRequest( 21 | user_information=UserInformation( 22 | InitiateRequest(proposed_conformance=Conformance()) 23 | ) 24 | ) 25 | ) 26 | assert s.current_state == state.AWAITING_ASSOCIATION_RESPONSE 27 | 28 | 29 | def test_aare_sets_ready_on_waiting_aare_response(): 30 | s = state.DlmsConnectionState(current_state=state.AWAITING_ASSOCIATION_RESPONSE) 31 | s.process_event( 32 | acse.ApplicationAssociationResponse( 33 | enumerations.AssociationResult.ACCEPTED, 34 | result_source_diagnostics=enumerations.AcseServiceUserDiagnostics.NULL, 35 | ) 36 | ) 37 | assert s.current_state == state.READY 38 | -------------------------------------------------------------------------------- /tests/test_general_global_cipher.py: -------------------------------------------------------------------------------- 1 | from dlms_cosem.connection import XDlmsApduFactory 2 | from dlms_cosem.protocol.xdlms import DataNotification, GeneralGlobalCipher 3 | from dlms_cosem.security import SecurityControlField 4 | 5 | 6 | def test_gen_glo_cipher_load(): 7 | dlms_data = b"\xdb\x08/\x19\"\x91\x99\x16A\x03;0\x00\x00\x01\xe5\x02\\\xe9\xd2'\x1f\xd7\x8b\xe8\xc2\x04!\x1a\x91j\x9d\x7fX~\nz\x81L\xad\xea\x89\xe9Y?\x01\xf9.\xa8\xc0\x87\xb5\xbd\xfd\xef\xea\xb6\xbe\xcf(-\xfeI\xc0\x8f[\xe6\xdc\x84\x00" 8 | 9 | system_title = b'/\x19"\x91\x99\x16A\x03' 10 | 11 | apdu = XDlmsApduFactory.apdu_from_bytes(apdu_bytes=dlms_data) 12 | 13 | assert isinstance(apdu, GeneralGlobalCipher) 14 | 15 | assert apdu.system_title == system_title 16 | 17 | 18 | def test_gen_glo_cipher_to_apdu(): 19 | dlms_data = b"\xdb\x08/\x19\"\x91\x99\x16A\x03;0\x00\x00\x01\xe5\x02\\\xe9\xd2'\x1f\xd7\x8b\xe8\xc2\x04!\x1a\x91j\x9d\x7fX~\nz\x81L\xad\xea\x89\xe9Y?\x01\xf9.\xa8\xc0\x87\xb5\xbd\xfd\xef\xea\xb6\xbe\xcf(-\xfeI\xc0\x8f[\xe6\xdc\x84\x00" 20 | 21 | system_title = b'/\x19"\x91\x99\x16A\x03' 22 | 23 | apdu = XDlmsApduFactory.apdu_from_bytes(apdu_bytes=dlms_data) 24 | 25 | assert isinstance(apdu, GeneralGlobalCipher) 26 | 27 | assert apdu.system_title == system_title 28 | 29 | unportected_apdu_data = apdu.to_plain_apdu( 30 | encryption_key=b"MYDUMMYGLOBALKEY", authentication_key=b"MYDUMMYGLOBALKEY" 31 | ) 32 | unportected_apdu = XDlmsApduFactory.apdu_from_bytes( 33 | apdu_bytes=unportected_apdu_data 34 | ) 35 | 36 | assert isinstance(unportected_apdu, DataNotification) 37 | 38 | 39 | def test_gen_glo_cipher_to_bytes(): 40 | dlms_data = b"\xdb\x08/\x19\"\x91\x99\x16A\x03;0\x00\x00\x01\xe5\x02\\\xe9\xd2'\x1f\xd7\x8b\xe8\xc2\x04!\x1a\x91j\x9d\x7fX~\nz\x81L\xad\xea\x89\xe9Y?\x01\xf9.\xa8\xc0\x87\xb5\xbd\xfd\xef\xea\xb6\xbe\xcf(-\xfeI\xc0\x8f[\xe6\xdc\x84\x00" 41 | 42 | apdu = XDlmsApduFactory.apdu_from_bytes(apdu_bytes=dlms_data) 43 | assert apdu.to_bytes() == dlms_data 44 | 45 | 46 | def test_data_notification_apdu(): 47 | dlms_data = b'\x0f\x00\x00\x01\xdb\x00\t"\x12Z\x85\x916\x00\x00\x00\x00I\x00\x00\x00\x11\x00\x00\x00\nZ\x85\x13\xd0\x14\x80\x00\x00\x00\r\x00\x00\x00\n\x01\x00' 48 | 49 | apdu = XDlmsApduFactory.apdu_from_bytes(apdu_bytes=dlms_data) 50 | 51 | assert isinstance(apdu, DataNotification) 52 | 53 | print(apdu) 54 | 55 | 56 | def test_gen_glo_cipher_with_no_system_title_encodes_correct(): 57 | ciphered = GeneralGlobalCipher( 58 | system_title=None, 59 | security_control=SecurityControlField( 60 | security_suite=0, authenticated=True, 61 | encrypted=True, broadcast_key=False, 62 | compressed=False), 63 | invocation_counter=2147483857, 64 | ciphered_text=b'\x81\xec\x9e\xc4\xbfS\xe9wn\xf0\xc4S\x96\x9f\xbd\xfe\x11\xbe\x9by\x1a\xac\xc0\xff\x8c') 65 | 66 | correct_result = b"\xdb\x00\x1e0\x80\x00\x00\xd1\x81\xec\x9e\xc4\xbfS\xe9wn\xf0\xc4S\x96\x9f\xbd\xfe\x11\xbe\x9by\x1a\xac\xc0\xff\x8c" 67 | assert ciphered.to_bytes() == correct_result 68 | -------------------------------------------------------------------------------- /tests/test_hdlc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/tests/test_hdlc/__init__.py -------------------------------------------------------------------------------- /tests/test_parsers.py: -------------------------------------------------------------------------------- 1 | from dlms_cosem import cosem, enumerations 2 | from dlms_cosem.parsers import ProfileGenericBufferParser 3 | 4 | 5 | def test_parse_buffer(): 6 | data = b"\x01\x04\x02\x04\t\x0c\x07\xe3\x0c\x1f\x02\x17\x00\x00\x00\xff\xc4\x00\x11\x06\x06\x00\x00\x05\xed\x06\x00\x00\x06T\x02\x04\x00\x11\x06\x06\x00\x00\x05\xed\x06\x00\x00\x06T\x02\x04\x00\x11\x06\x06\x00\x00\x05\xed\x06\x00\x00\x06T\x02\x04\x00\x11\x06\x06\x00\x00\x05\xed\x06\x00\x00\x06T" 7 | 8 | parser = ProfileGenericBufferParser( 9 | capture_objects=[ 10 | cosem.CosemAttribute( 11 | interface=enumerations.CosemInterface.CLOCK, 12 | instance=cosem.Obis(0, 0, 1, 0, 0, 255), 13 | attribute=2, 14 | ), 15 | cosem.CosemAttribute( 16 | interface=enumerations.CosemInterface.DATA, 17 | instance=cosem.Obis(0, 0, 96, 10, 1, 255), 18 | attribute=2, 19 | ), 20 | cosem.CosemAttribute( 21 | interface=enumerations.CosemInterface.REGISTER, 22 | instance=cosem.Obis(1, 0, 1, 8, 0, 255), 23 | attribute=2, 24 | ), 25 | cosem.CosemAttribute( 26 | interface=enumerations.CosemInterface.REGISTER, 27 | instance=cosem.Obis(1, 0, 2, 8, 0, 255), 28 | attribute=2, 29 | ), 30 | ], 31 | capture_period=60, 32 | ) 33 | result = parser.parse_bytes(data) 34 | 35 | assert len(result) == 4 36 | assert len(result[0]) == 4 37 | assert result[0][0].attribute.attribute == 2 38 | assert result[0][0].attribute.interface == enumerations.CosemInterface.CLOCK 39 | assert result[0][0].attribute.instance.to_string() == "0-0:1.0.0.255" 40 | assert (result[1][0].value - result[0][0].value).total_seconds() == 60 * 60 41 | -------------------------------------------------------------------------------- /tests/test_security.py: -------------------------------------------------------------------------------- 1 | from cryptography.hazmat.primitives.ciphers import algorithms, modes 2 | from cryptography.hazmat.primitives.ciphers.base import Cipher 3 | 4 | from dlms_cosem.security import SecurityControlField, decrypt, encrypt, gmac 5 | 6 | 7 | def test_encrypt(): 8 | key = b"SUCHINSECUREKIND" 9 | auth_key = key 10 | 11 | text = b"SUPER_SECRET_TEXT" 12 | 13 | ctext = encrypt( 14 | key=key, 15 | auth_key=auth_key, 16 | invocation_counter=1, 17 | security_control=SecurityControlField( 18 | security_suite=0, authenticated=True, encrypted=True 19 | ), 20 | system_title=b"12345678", 21 | plain_text=text, 22 | ) 23 | 24 | print(ctext) 25 | 26 | out = decrypt( 27 | key=key, 28 | auth_key=auth_key, 29 | invocation_counter=1, 30 | security_control=SecurityControlField( 31 | security_suite=0, authenticated=True, encrypted=True 32 | ), 33 | system_title=b"12345678", 34 | cipher_text=ctext, 35 | ) 36 | 37 | assert text == out 38 | 39 | 40 | def test_encrypt_authenticated(): 41 | security_control = SecurityControlField( 42 | security_suite=0, authenticated=True, encrypted=True 43 | ) 44 | encryption_key = bytes.fromhex("000102030405060708090A0B0C0D0E0F") 45 | authentication_key = bytes.fromhex("D0D1D2D3D4D5D6D7D8D9DADBDCDDDEDF") 46 | system_title = bytes.fromhex("4D4D4D0000BC614E") 47 | invocation_counter = int.from_bytes(bytes.fromhex("01234567"), "big") 48 | # Get request attr 2 of clock object. 49 | plain_data = bytes.fromhex("C0010000080000010000FF0200") 50 | 51 | ciphered_text = bytes.fromhex("411312FF935A47566827C467BC7D825C3BE4A77C3FCC056B6B") 52 | 53 | assert ( 54 | encrypt( 55 | security_control=security_control, 56 | key=encryption_key, 57 | auth_key=authentication_key, 58 | system_title=system_title, 59 | invocation_counter=invocation_counter, 60 | plain_text=plain_data, 61 | ) 62 | == ciphered_text 63 | ) 64 | 65 | 66 | def test_decrypt_authenticated(): 67 | security_control = SecurityControlField( 68 | security_suite=0, authenticated=True, encrypted=True 69 | ) 70 | encryption_key = bytes.fromhex("000102030405060708090A0B0C0D0E0F") 71 | authentication_key = bytes.fromhex("D0D1D2D3D4D5D6D7D8D9DADBDCDDDEDF") 72 | system_title = bytes.fromhex("4D4D4D0000BC614E") 73 | invocation_counter = int.from_bytes(bytes.fromhex("01234567"), "big") 74 | # Get request attr 2 of clock object. 75 | plain_data = bytes.fromhex("C0010000080000010000FF0200") 76 | 77 | ciphered_text = bytes.fromhex("411312FF935A47566827C467BC7D825C3BE4A77C3FCC056B6B") 78 | 79 | assert ( 80 | decrypt( 81 | security_control=security_control, 82 | key=encryption_key, 83 | auth_key=authentication_key, 84 | system_title=system_title, 85 | invocation_counter=invocation_counter, 86 | cipher_text=ciphered_text, 87 | ) 88 | == plain_data 89 | ) 90 | 91 | 92 | def test_gmac(): 93 | encryption_key = bytes.fromhex("000102030405060708090A0B0C0D0E0F") 94 | authentication_key = bytes.fromhex("D0D1D2D3D4D5D6D7D8D9DADBDCDDDEDF") 95 | security_control = SecurityControlField( 96 | security_suite=0, authenticated=True, encrypted=False 97 | ) 98 | client_invocation_counter = int.from_bytes(bytes.fromhex("00000001"), "big") 99 | client_system_title = bytes.fromhex("4D4D4D0000000001") 100 | # server_system_title = bytes.fromhex("4D4D4D0000BC614E") 101 | # server_invocation_counter = int.from_bytes(bytes.fromhex("01234567"), "big") 102 | # client_to_server_challenge = bytes.fromhex("4B35366956616759") 103 | server_to_client_challenge = bytes.fromhex("503677524A323146") 104 | result = gmac( 105 | security_control=security_control, 106 | key=encryption_key, 107 | auth_key=authentication_key, 108 | invocation_counter=client_invocation_counter, 109 | system_title=client_system_title, 110 | challenge=server_to_client_challenge, 111 | ) 112 | assert len(result) == 12 113 | assert result == bytes.fromhex("1A52FE7DD3E72748973C1E28") 114 | 115 | 116 | def test_gmac2(): 117 | encryption_key = bytes.fromhex("000102030405060708090A0B0C0D0E0F") 118 | authentication_key = bytes.fromhex("D0D1D2D3D4D5D6D7D8D9DADBDCDDDEDF") 119 | security_control = SecurityControlField( 120 | security_suite=0, authenticated=True, encrypted=False 121 | ) 122 | client_invocation_counter = int.from_bytes(bytes.fromhex("00000001"), "big") 123 | client_system_title = bytes.fromhex("4D4D4D0000000001") 124 | # server_system_title = bytes.fromhex("4D4D4D0000BC614E") 125 | # server_invocation_counter = int.from_bytes(bytes.fromhex("01234567"), "big") 126 | # client_to_server_challenge = bytes.fromhex("4B35366956616759") 127 | server_to_client_challenge = bytes.fromhex("503677524A323146") 128 | 129 | iv = client_system_title + client_invocation_counter.to_bytes(4, "big") 130 | 131 | assert iv == bytes.fromhex("4D4D4D000000000100000001") 132 | 133 | # Construct an AES-GCM Cipher object with the given key and iv 134 | encryptor = Cipher( 135 | algorithms.AES(encryption_key), 136 | modes.GCM(initialization_vector=iv, tag=None, min_tag_length=12), 137 | ).encryptor() 138 | 139 | # associated_data will be authenticated but not encrypted, 140 | # it must also be passed in on decryption. 141 | associated_data = ( 142 | security_control.to_bytes() + authentication_key + server_to_client_challenge 143 | ) 144 | 145 | assert associated_data == bytes.fromhex( 146 | "10D0D1D2D3D4D5D6D7D8D9DADBDCDDDEDF503677524A323146" 147 | ) 148 | encryptor.authenticate_additional_data(associated_data) 149 | 150 | # Encrypt the plaintext and get the associated ciphertext. 151 | # GCM does not require padding. 152 | ciphertext = encryptor.update(b"") + encryptor.finalize() 153 | 154 | # dlms uses a tag lenght of 12 not the default of 16. Since we have set the minimum 155 | # tag length to 12 it is ok to truncated the tag. 156 | tag = encryptor.tag[:12] 157 | 158 | assert ciphertext == b"" 159 | result = ciphertext + tag 160 | 161 | assert result == bytes.fromhex("1A52FE7DD3E72748973C1E28") 162 | -------------------------------------------------------------------------------- /tests/test_time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | from dateutil.parser import parse as dt_parse 5 | 6 | from dlms_cosem.time import datetime_from_bytes, datetime_to_bytes 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "bytes_representation, dt", 11 | [ 12 | ( 13 | b"\x07\xe4\x01\x01\xff\x00\x03\x00\x00\xff\x88\x00", 14 | dt_parse("2020-01-01T00:03:00+02:00"), 15 | ), 16 | ( 17 | b"\x07\xe4\x01\x06\xff\x00\x03\x00\x00\xff\xc4\x00", 18 | dt_parse("2020-01-06T00:03:00+01:00"), 19 | ), 20 | ( 21 | b"\x07\xe2\x02\x0c\xff\x00\x00\x00\x00\x80\x00\x00", 22 | dt_parse("2018-02-12T00:00:00"), 23 | ), 24 | ], 25 | ) 26 | def test_bytes_datetime_conversion(bytes_representation: bytes, dt: datetime.datetime): 27 | 28 | assert datetime_from_bytes(bytes_representation)[0] == dt 29 | assert datetime_to_bytes(dt) == bytes_representation 30 | -------------------------------------------------------------------------------- /tests/test_udp_message.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dlms_cosem.protocol.wrappers import DlmsUdpMessage, WrapperHeader 4 | 5 | data_examples_encrypted_data_nofication = [ 6 | b"\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19\"\x91\x99\x16A\x03;0\x00\x00\x01\xe5\x02\\\xe9\xd2'\x1f\xd7\x8b\xe8\xc2\x04!\x1a\x91j\x9d\x7fX~\nz\x81L\xad\xea\x89\xe9Y?\x01\xf9.\xa8\xc0\x87\xb5\xbd\xfd\xef\xea\xb6\xbe\xcf(-\xfeI\xc0\x8f[\xe6\xdc\x84\x00", 7 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xe6\x03\xd4\xd3W{\x7fd\x994\xe3\xb7\xc7\x19\xa3\xde5\x1a\xb2\x8cz\xc7\xb8\xa1\xe4D\xb8\x96\x91\xe9%\x91\xce\x1e\xb2\x82}\xf97\xa2\xe5@(\x0fb\x11\xf4\x93d\x80/\xa0\xf5\xc4\x13', 8 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xe7\x1c+\xbc?\xfb\x9aN9x\xf2k\xfa\xf5\xe9A\xe2i\xa2\xb6\x1dG\xb46\x1b/[\x1d"\xf5\xa0N\xffp\x8c\x9f\xfbI<@\x16:\x0e\x19x\xb7D\x9c\xec\x9c\xca\xe0\x8d\x19D', 9 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xe8\xb1\xf9[\xdd.\xdbA\xd3V\xdbW\xeeQ, \xc6\xeace:U\xbb\x18q~A\x9fE\xe8\xd3\xb4\xf3C)\xf4\xce\xb2\x1c\x81A\xa7\xe3\xcc\x00\xf0k~-\x98\xd7j\xf4\xb8\x06', 10 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xe9\xfd\x1c&\xa0\xa1\xa8\x8b\x86\xf3\xdc \x10\xb1{\xeb\xa3h\xa3\xb6\xd2\xad\x96SZ\xd4\x1f\x84\xd6\xcbi\xa86]\xb4\x1b\x8c\xac\xb5D\x94v\xc3\xf4 \xe1\x86\xffk\x1b`E\x11p\x08', 11 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xea3\x80\xbdH\x91\x00v\x18]\xa7|\xf9\xd0\xf5v\xc4{\n\xc0\x98\xef\xb3~\xb7u\x89\x8e\x9c\xcde\x02\x13\xa7?&\x9f\x8c{\xea8N\xd3\x88\xe7\xcc\xd2\x05\x06\xfe7;\x06\x8b:', 12 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xeb0J\xf3\x911\xd5\xa6J\x06\xb2\xbb\xa8\xf1\xb9]\xd2+\xfd\xa4]9\xad\xcb\x08\x89\xe3\x03s4\x0f7\xc5\x80\xd3"f\x89>\xc7\'\xae.\xef\xe2\xd1Z8\x89\xab\xd1\x85\x94\x005', 13 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xecho\xf7\xf6\xd0\x9a\x96+\xe5:\xcc\x95\xe1\xe4\xc6\xfeO\xb1[\xfd\xa2\x93\xe2\xae\xcd\x85]\x7f\xaa\xc7\x99\x8cXQ\xce\x038f`E\xa6\xcf\x87\x924V\xf8\xb1+\x02\xb6.\xfc\xed', 14 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xee\xf4\x86`\x0f\xf8\xcf\x8dMA!\xe1B>Q\r\x9c\x87)\xf4\x8b!b\x85t\xfe\x16\xd9\xcbT\x06sL\xefW\x14H\x7f\xf6#\x10\xa4?\x1av\x00L\xa5`\x1b\xbf>\xf9c\x9f', 15 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xefA&8\xb9C\xa0\xfe\xc2,\x8d\x02\xb4\xc4\xb7}\x9es\x8d\x98\xe3q\t\xdb\x85\x12\\\x14\x9f\xa9\xdf=I\xe3\t\xf9\xc3\xa5\xb3\x81\x0b5\xed\x9fVx\xb4\xc7\x81y.\xb8>n+', 16 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xf0N@\xc8{\xde\xb0\xc12\xbfI"\xdf\xc2\x98\xae~pt\xf3\xec_\x1e\x0f\x93\xf36\xfd\x84\xa2\xdf\xb2\xbc\x0b\xed\x80\x84\xf4\xf2\xcf\xebzf\xb1\x16\xd2E\xc8\xb1k\x93\xefM\x1f\x88', 17 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xf1\xce\xef\x1e-\xb6ad\x9a\xbc?\xc4\x1by+\x9a\xd5\xa9\xf0 J\xa16{i\xd5\xdc\x18\x0f\x8c\xd8\xaf\x8d\x99%\x9d\x1d\xfa\x16[\xaa\tg\xb1\xcej\xb9\x8a\xf8\xa5\xdb\x94(\xd3G', 18 | b'\x00\x01\x00\x01\x00\x01\x00F\xdb\x08/\x19"\x91\x99\x16A\x03;0\x00\x00\x01\xf2\xeb\xae\xa2s\xd5.\xd6V\xc0\x97wM\x08=G%]\x88b\xb57\x1d\xc0l\xf1 \xdcU\x81z;\x91\xc3\x86\xac/g\xca\xf7\x94\x1a=\x01\xb2\xb6|\xdd\x9d{\xbb\x871\x12K', 19 | ] 20 | 21 | 22 | def test_udp_message_from_bytes(): 23 | udp_header_data = b"\x00\x01\x00\x01\x00\x01\x00F" 24 | dlms_data = b"\xdb\x08/\x19\"\x91\x99\x16A\x03;0\x00\x00\x01\xe5\x02\\\xe9\xd2'\x1f\xd7\x8b\xe8\xc2\x04!\x1a\x91j\x9d\x7fX~\nz\x81L\xad\xea\x89\xe9Y?\x01\xf9.\xa8\xc0\x87\xb5\xbd\xfd\xef\xea\xb6\xbe\xcf(-\xfeI\xc0\x8f[\xe6\xdc\x84\x00" 25 | 26 | udp_data = udp_header_data + dlms_data 27 | 28 | message = DlmsUdpMessage.from_bytes(in_data=udp_data) 29 | 30 | assert message.wrapper_header.version == 1 31 | assert message.wrapper_header.source_wport == 1 32 | assert message.wrapper_header.destination_wport == 1 33 | assert message.wrapper_header.version == 1 34 | assert message.wrapper_header.length == 70 35 | assert message.data == dlms_data 36 | 37 | 38 | def test_upd_message_to_bytes(): 39 | udp_header_data = b"\x00\x01\x00\x01\x00\x01\x00F" 40 | dlms_data = b"\xdb\x08/\x19\"\x91\x99\x16A\x03;0\x00\x00\x01\xe5\x02\\\xe9\xd2'\x1f\xd7\x8b\xe8\xc2\x04!\x1a\x91j\x9d\x7fX~\nz\x81L\xad\xea\x89\xe9Y?\x01\xf9.\xa8\xc0\x87\xb5\xbd\xfd\xef\xea\xb6\xbe\xcf(-\xfeI\xc0\x8f[\xe6\xdc\x84\x00" 41 | 42 | udp_data = udp_header_data + dlms_data 43 | wrapper_header = WrapperHeader( 44 | source_wport=1, destination_wport=1, length=len(dlms_data) 45 | ) 46 | message = DlmsUdpMessage(data=dlms_data, wrapper_header=wrapper_header) 47 | assert message.wrapper_header.version == 1 48 | assert message.wrapper_header.source_wport == 1 49 | assert message.wrapper_header.destination_wport == 1 50 | assert message.wrapper_header.version == 1 51 | assert message.wrapper_header.length == 70 52 | assert message.data == dlms_data 53 | 54 | assert message.to_bytes() == udp_data 55 | 56 | 57 | # def test_udp_parsing(): 58 | # udp = UDPRequest(data_examples_encrypted_data_nofication[0]) 59 | # 60 | # assert (udp) == 2 61 | # 62 | # # it is a general global ciphering APDU 63 | # 64 | # a = (b'\x00\x01\x00\x01\x00\x01\x00F' # UDP wrapper 65 | # b'\xdb' # general global ciphering tag 66 | # b'\x08/\x19"\x91\x99\x16A\x03' # system title 67 | # b';0\x00\x00\x01\xf2' # security Control field = 0b00110000 No compression, unicast, encrypted authenticated. length = 59 bytes = OK 68 | # b'\xeb\xae\xa2s\xd5.\xd6V\xc0\x97wM\x08=G%]\x88b\xb57\x1d\xc0l\xf1 \xdcU\x81z;\x91\xc3\x86\xac/g\xca\xf7\x94\x1a=' 69 | # b'\x01\xb2\xb6|\xdd\x9d{\xbb\x871\x12K') # auth tag) 70 | -------------------------------------------------------------------------------- /tests/test_xdlms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u9n/dlms-cosem/db9a52ac158157b0a12f0118cc572f0e9ae0f880/tests/test_xdlms/__init__.py -------------------------------------------------------------------------------- /tests/test_xdlms/test_data_notification.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dlms_cosem import utils 4 | from dlms_cosem.protocol import xdlms 5 | 6 | 7 | class TestDataNotification: 8 | def test_transform_bytes(self): 9 | dlms_data = b'\x0f\x00\x00\x01\xdb\x00\t"\x12Z\x85\x916\x00\x00\x00\x00I\x00\x00\x00\x11\x00\x00\x00\nZ\x85\x13\xd0\x14\x80\x00\x00\x00\r\x00\x00\x00\n\x01\x00' 10 | data_notification = xdlms.DataNotification.from_bytes(dlms_data) 11 | print(data_notification) 12 | assert data_notification.date_time is None 13 | assert ( 14 | data_notification.body 15 | == b'\t"\x12Z\x85\x916\x00\x00\x00\x00I\x00\x00\x00\x11\x00\x00\x00\nZ\x85\x13\xd0\x14\x80\x00\x00\x00\r\x00\x00\x00\n\x01\x00' 16 | ) 17 | utils.parse_as_dlms_data(data_notification.body) 18 | assert data_notification.to_bytes() == dlms_data 19 | -------------------------------------------------------------------------------- /tests/test_xdlms/test_selective_access.py: -------------------------------------------------------------------------------- 1 | from dateutil import parser 2 | 3 | from dlms_cosem import cosem, enumerations 4 | from dlms_cosem.cosem import selective_access 5 | from dlms_cosem.cosem.selective_access import RangeDescriptor 6 | from dlms_cosem.protocol.xdlms import GetRequestFactory 7 | 8 | 9 | def test_capture_object_definition(): 10 | x = selective_access.CaptureObject( 11 | cosem_attribute=cosem.CosemAttribute( 12 | interface=enumerations.CosemInterface.CLOCK, 13 | instance=cosem.Obis(0, 0, 1, 0, 0, 255), 14 | attribute=2, 15 | ), 16 | data_index=0, 17 | ) 18 | 19 | assert x.to_bytes() == ( 20 | b"\x02\x04" # structure of 4 elements 21 | b"\x12\x00\x08" # Clock interface class (unsigned long int) 22 | b"\t\x06\x00\x00\x01\x00\x00\xff" # Clock instance octet string of 0.0.1.0.0.255 23 | b"\x0f\x02" # attribute index = 2 (integer) 24 | b"\x12\x00\x00" # data index = 0 (long unsigned) 25 | ) 26 | 27 | 28 | def test_range_descriptor1(): 29 | data = ( 30 | b"\xc0" # Get request 31 | b"\x01" # normal 32 | b"\xc1" # invoke id and priority 33 | b"\x00\x07" # Profile generic 34 | b"\x01\x00c\x01\x00\xff" # 1.0.99.1.0.255 35 | b"\x02" # Attribute 2 = buffer 36 | b"\x01" # non default value 37 | b"\x01" # descriptor 1 (range-access) 38 | b"\x02\x04" # strucutre of 4 elements 39 | b"\x02\x04" # strucutre of 4 elements 40 | b"\x12\x00\x08" # clock interface class 41 | b"\t\x06\x00\x00\x01\x00\x00\xff" # clock instance name. 0.0.1.0.0.255 42 | b"\x0f\x02" # attribute 2 43 | b"\x12\x00\x00" # data index = 0 44 | b"\t\x0c\x07\xe2\x06\x01\xff\x00\x03\x00\xff\xff\x88\x80" # from date 45 | b"\t\x0c\x07\xe5\x01\x06\xff\x00\x03\x00\xff\xff\xc4\x00" # to date 46 | b"\x01\x00" # all columns 47 | ) 48 | assert data 49 | 50 | data2 = ( 51 | b"\xc0\x01\xc1\x00\x07\x01\x00c\x01\x00\xff\x02\x01\x01" 52 | b"\x02\x04" 53 | b"\x02\x04" 54 | b"\x12\x00\x08" 55 | b"\t\x06\x00\x00\x01\x00\x00\xff" 56 | b"\x0f\x02" 57 | b"\x12\x00\x00" 58 | b"\t\x0c\x07\xe2\x02\x0c\xff\x00\x00\x00\x00\x80\x00\x00" 59 | b"\t\x0c\x07\xe3\x02\x0c\xff\x00\x00\x00\x00\x80\x00\x00" 60 | b"\x01\x00" 61 | ) 62 | assert data2 63 | 64 | 65 | def test_range_descriptor_to_bytes(): 66 | rd = RangeDescriptor( 67 | restricting_object=selective_access.CaptureObject( 68 | cosem_attribute=cosem.CosemAttribute( 69 | interface=enumerations.CosemInterface.CLOCK, 70 | instance=cosem.Obis(0, 0, 1, 0, 0, 255), 71 | attribute=2, 72 | ), 73 | data_index=0, 74 | ), 75 | from_value=parser.parse("2020-01-01T00:03:00+02:00"), 76 | to_value=parser.parse("2020-01-06T00:03:00+01:00"), 77 | ) 78 | data = b"\x01\x02\x04\x02\x04\x12\x00\x08\t\x06\x00\x00\x01\x00\x00\xff\x0f\x02\x12\x00\x00\t\x0c\x07\xe4\x01\x01\xff\x00\x03\x00\x00\xff\x88\x00\t\x0c\x07\xe4\x01\x06\xff\x00\x03\x00\x00\xff\xc4\x00\x01\x00" 79 | assert rd.to_bytes() == data 80 | 81 | 82 | def test_parse_range_descriptor(): 83 | 84 | """ 85 | Profile: 1 (15 minutes profile) 86 | From: 01.10.2017 00:00 87 | To: 01.10.2017 01:00 88 | 89 | """ 90 | 91 | data = b"\xc0\x01\xc1\x00\x07\x01\x00c\x01\x00\xff\x02\x01\x01\x02\x04\x02\x04\x12\x00\x08\t\x06\x00\x00\x01\x00\x00\xff\x0f\x02\x12\x00\x00\t\x0c\x07\xe1\n\x01\x07\x00\x00\x00\x00\xff\xc4\x80\t\x0c\x07\xe1\n\x01\x07\x01\x00\x00\x00\xff\xc4\x80\x01\x00" 92 | g = GetRequestFactory.from_bytes(data) 93 | 94 | access = ( 95 | b"\x01" # Optional value used 96 | b"\x01" # Access selector 97 | b"\x02\x04" # Structure of 4 elements 98 | b"\x02\x04" # structure of 4 elements 99 | b"\x12\x00\x08" # Clock interface class (unsigned long int) 100 | b"\t\x06\x00\x00\x01\x00\x00\xff" # Clock instance octet string of 0.0.1.0.0.255 101 | b"\x0f\x02" # attribute index = 2 (integer) 102 | b"\x12\x00\x00" # data index = 0 (long unsigned) 103 | b"\t\x0c\x07\xe1\n\x01\x07\x00\x00\x00\x00\xff\xc4\x80" # from date 104 | b"\t\x0c\x07\xe1\n\x01\x07\x01\x00\x00\x00\xff\xc4\x80" # to date 105 | b"\x01\x00" # selected_values empty array. 106 | ) 107 | assert access 108 | 109 | access_selection = g.access_selection 110 | 111 | assert isinstance(access_selection, RangeDescriptor) 112 | assert access_selection.selected_values is None 113 | assert ( 114 | access_selection.restricting_object.cosem_attribute.instance.to_string() 115 | == "0-0:1.0.0.255" 116 | ) 117 | -------------------------------------------------------------------------------- /tests/test_xdlms/test_set.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dlms_cosem import cosem, enumerations 4 | from dlms_cosem.protocol import xdlms 5 | 6 | 7 | class TestSetRequestNormal: 8 | def test_transform_bytes(self): 9 | data = b"\xc1\x01\xc1\x00\x08\x00\x00\x01\x00\x00\xff\x02\x00\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00" 10 | request = xdlms.SetRequestNormal( 11 | cosem_attribute=cosem.CosemAttribute( 12 | interface=enumerations.CosemInterface.CLOCK, 13 | instance=cosem.Obis(a=0, b=0, c=1, d=0, e=0, f=255), 14 | attribute=2, 15 | ), 16 | data=b"\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00", 17 | access_selection=None, 18 | invoke_id_and_priority=xdlms.InvokeIdAndPriority( 19 | invoke_id=1, confirmed=True, high_priority=True 20 | ), 21 | ) 22 | assert data == request.to_bytes() 23 | assert request == xdlms.SetRequestNormal.from_bytes(data) 24 | 25 | def test_wrong_tag_raises_value_error(self): 26 | data = b"\xc2\x01\xc1\x00\x08\x00\x00\x01\x00\x00\xff\x02\x00\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00" 27 | with pytest.raises(ValueError): 28 | xdlms.SetRequestNormal.from_bytes(data) 29 | 30 | def test_wrong_type_raises_value_error(self): 31 | 32 | data = b"\xc1\x02\xc1\x00\x08\x00\x00\x01\x00\x00\xff\x02\x00\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00" 33 | with pytest.raises(ValueError): 34 | xdlms.SetRequestNormal.from_bytes(data) 35 | 36 | 37 | class TestSetRequestFactory: 38 | def test_set_request_normal(self): 39 | data = b"\xc1\x01\xc1\x00\x08\x00\x00\x01\x00\x00\xff\x02\x00\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00" 40 | request = xdlms.SetRequestFactory.from_bytes(data) 41 | assert isinstance(request, xdlms.SetRequestNormal) 42 | 43 | def test_wrong_tag_raises_value_error(self): 44 | data = b"\xc2\x01\xc1\x00\x08\x00\x00\x01\x00\x00\xff\x02\x00\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00" 45 | with pytest.raises(ValueError): 46 | xdlms.SetRequestFactory.from_bytes(data) 47 | 48 | def test_request_with_first_block_raises_not_implemented_error(self): 49 | data = b"\xc1\x02\xc1\x00\x08\x00\x00\x01\x00\x00\xff\x02\x00\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00" 50 | with pytest.raises(NotImplementedError): 51 | xdlms.SetRequestFactory.from_bytes(data) 52 | 53 | def test_set_request_with_block_raises_not_implemented_error(self): 54 | data = b"\xc1\x03\xc1\x00\x08\x00\x00\x01\x00\x00\xff\x02\x00\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00" 55 | with pytest.raises(NotImplementedError): 56 | xdlms.SetRequestFactory.from_bytes(data) 57 | 58 | def test_set_with_list_raises_not_implemented_error(self): 59 | data = b"\xc1\x04\xc1\x00\x08\x00\x00\x01\x00\x00\xff\x02\x00\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00" 60 | with pytest.raises(NotImplementedError): 61 | xdlms.SetRequestFactory.from_bytes(data) 62 | 63 | def test_set_request_with_list_first_block_raises_not_implemented_block(self): 64 | data = b"\xc1\x05\xc1\x00\x08\x00\x00\x01\x00\x00\xff\x02\x00\t\x0c\x07\xe5\x01\x18\xff\x0e09P\xff\xc4\x00" 65 | with pytest.raises(NotImplementedError): 66 | xdlms.SetRequestFactory.from_bytes(data) 67 | 68 | 69 | class TestSetResponseNormal: 70 | def test_transform_bytes(self): 71 | data = b"\xc5\x01\xc1\x00" 72 | response = xdlms.SetResponseNormal( 73 | result=enumerations.DataAccessResult.SUCCESS, 74 | invoke_id_and_priority=xdlms.InvokeIdAndPriority( 75 | invoke_id=1, confirmed=True, high_priority=True 76 | ), 77 | ) 78 | assert data == response.to_bytes() 79 | assert response == xdlms.SetResponseNormal.from_bytes(data) 80 | 81 | def test_wrong_tag_raises_value_error(self): 82 | data = b"\xc6\x01\xc1\x00" 83 | with pytest.raises(ValueError): 84 | xdlms.SetRequestNormal.from_bytes(data) 85 | 86 | def test_wrong_type_raises_value_error(self): 87 | data = b"\xc5\x02\xc1\x00" 88 | with pytest.raises(ValueError): 89 | xdlms.SetRequestNormal.from_bytes(data) 90 | 91 | 92 | class TestSetResponseFactory: 93 | def test_set_response_normal(self): 94 | data = b"\xc5\x01\xc1\x00" 95 | request = xdlms.SetResponseFactory.from_bytes(data) 96 | assert isinstance(request, xdlms.SetResponseNormal) 97 | 98 | def test_wrong_tag_raises_value_error(self): 99 | data = b"\xc6\x01\xc1\x00" 100 | with pytest.raises(ValueError): 101 | xdlms.SetResponseFactory.from_bytes(data) 102 | 103 | def test_set_response_with_block_raises_not_implemented_error(self): 104 | data = b"\xc5\x02\xc1\x00" 105 | with pytest.raises(NotImplementedError): 106 | xdlms.SetResponseFactory.from_bytes(data) 107 | 108 | def test_set_response_last_block_raises_not_implemented_error(self): 109 | data = b"\xc5\x03\xc1\x00" 110 | with pytest.raises(NotImplementedError): 111 | xdlms.SetResponseFactory.from_bytes(data) 112 | 113 | def test_set_response_last_block_with_list_raises_not_implemented_error(self): 114 | data = b"\xc5\x04\xc1\x00" 115 | with pytest.raises(NotImplementedError): 116 | xdlms.SetResponseFactory.from_bytes(data) 117 | 118 | def test_set_response_with_list_raises_not_implemented_error(self): 119 | data = b"\xc5\x05\xc1\x00" 120 | with pytest.raises(NotImplementedError): 121 | xdlms.SetResponseFactory.from_bytes(data) 122 | --------------------------------------------------------------------------------