├── .env ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .python-version ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── doc ├── command_line.md ├── developer.md ├── process_data.md └── virtual_process_data.md ├── examples ├── read_process_data.py └── read_virtual_process_data.py ├── pykoplenti ├── __init__.py ├── api.py ├── cli.py ├── extended.py ├── model.py └── py.typed ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── test_cli.py ├── test_extendedapiclient.py ├── test_pykoplenti.py └── test_smoketest.py └── tox.ini /.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stegm/pykoplenti/6537ebae1bb94bbeab8f44cec9f73724f9df6345/.env -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.9", "3.10", "3.11", "3.12"] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependecies 19 | run: | 20 | python -m pip install --upgrade pip 21 | python -m pip install pipenv 22 | pipenv install --dev --python ${{ matrix.python-version }} 23 | - name: Lint with ruff 24 | run: | 25 | pipenv run ruff --output-format=github . 26 | - name: Type check with mypy 27 | run: | 28 | pipenv run mypy pykoplenti/ tests/ 29 | - name: Test with pytest 30 | run: | 31 | pipenv run pytest --junitxml=junit/test-results.xml --cov pykoplenti --cov-report=xml --cov-report=html 32 | - name: Test with tox 33 | run: | 34 | pipenv run tox 35 | - name: Build package 36 | run: | 37 | pipenv run build 38 | - name: Upload packages to github 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: dist-${{ matrix.python-version }} 42 | path: dist/* 43 | 44 | deploy: 45 | if: startsWith(github.event.ref, 'refs/tags/v') 46 | runs-on: ubuntu-latest 47 | environment: 48 | name: pypi 49 | url: https://pypi.org/p/pykoplenti 50 | permissions: 51 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishi 52 | steps: 53 | - uses: actions/checkout@v2 54 | - name: Set up Python ${{ matrix.python-version }} 55 | uses: actions/setup-python@v2 56 | with: 57 | python-version: "3.10" 58 | - name: Install dependecies 59 | run: | 60 | python -m pip install --upgrade pip 61 | python -m pip install pipenv 62 | pipenv install --dev 63 | - name: Build package 64 | run: | 65 | pipenv run build 66 | - name: Publish package distributions to PyPI 67 | uses: pypa/gh-action-pypi-publish@release/v1 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /secrets 2 | /credentials* 3 | /venv 4 | __pycache__ 5 | *.egg-info 6 | build/ 7 | dist/ 8 | .tox/ 9 | pip-wheel-metadata/ 10 | .env.local 11 | coverage.xml 12 | .coverage 13 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Start CLI", 9 | "type": "python", 10 | "request": "launch", 11 | "envFile": "${workspaceFolder}/.env.local", 12 | "program": "${workspaceFolder}/pykoplenti/cli.py", 13 | "args": ["repl"], 14 | "console": "integratedTerminal", 15 | "justMyCode": true 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black", 3 | "editor.formatOnSave": true, 4 | "ruff.organizeImports": true 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.4.0] - 2025-04-01 9 | 10 | ### Changed 11 | 12 | - The cli now supports installer authentication, see `--service-code` option 13 | - New credential file format for cli that supports password and service code 14 | 15 | ## Deprecated 16 | 17 | - Option `--password-file` is now deprecated. Use `--credentials` instead. 18 | 19 | ## [1.3.0] - 2024-11-13 20 | 21 | ## Changed 22 | 23 | - Added support for pydantic 2.x (>=2.0.0) while maintaining compatibility 24 | with pydantic 1.x (>=1.9.0). 25 | - Dropped support for Python 3.8 and added 3.11/3.12. 26 | 27 | ## Fixed 28 | 29 | - Fixed error in cli for printing events. 30 | 31 | ## [1.2.2] - 2023-11-12 32 | 33 | ## Changed 34 | 35 | - Loosen version for required package aiohttp (Dependency to Home Assistant). 36 | 37 | ## [1.2.1] - 2023-11-11 38 | 39 | ### Changed 40 | 41 | - Downgrade pydantic to 1.x (Dependency to Home Assistant). 42 | 43 | ## [1.2.0] - 2023-11-06 44 | 45 | ### Changed 46 | 47 | - All models are now based on pydantic - interface is still the same. 48 | - Code is refactored into separate modules - imports are still provided by using `import pykoplenti` 49 | 50 | ### Fixed 51 | 52 | - If a request is anwered with 401, an automatic re-login is triggered (like this was already the case for 400 response). 53 | 54 | ### Added 55 | 56 | - A new api client `ExtendedApiClient` was added which provides virtual process data values. See [Virtual Process Data](doc/virtual_process_data.md) for details. 57 | - Package provide type hints via `py.typed`. 58 | 59 | ## [1.1.0] 60 | 61 | ### Added 62 | 63 | - Add installer authentication 64 | - Add a new class `pykoplenti.ExtendedApiClient` which provides virtual process ids for some common missing values. 65 | 66 | ## [1.0.0] - 2021-05-04 67 | 68 | ### Fixed 69 | 70 | - ProcessDataCollection can now return raw json response. 71 | 72 | ### Changed 73 | 74 | - Minimum Python Version is now 3.7 75 | - Change package metadata 76 | - Changed naming to simpler unique name 77 | 78 | ### Added 79 | 80 | - new function to read events from the inverter 81 | - new sub-command `read-events` for reading events 82 | - download of log data 83 | 84 | ## [0.2.0] - 2020-11-17 85 | 86 | ### Changed 87 | 88 | - Prepared for PyPI-Publishing 89 | - Allow reading setting values from multiple modules 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | pykoplenti = {editable = true, extras = ["cli"], path = "."} 8 | 9 | [dev-packages] 10 | black = "~=23.10" 11 | isort = "~=5.12" 12 | ruff = "*" 13 | pytest = "~=7.4" 14 | pytest-asyncio = "~=0.21" 15 | pytest-cov = "~=4.1" 16 | mypy = "~=1.6" 17 | build = "~=1.0" 18 | tox = "~=4.12" 19 | 20 | [requires] 21 | python_version = "3.10" 22 | 23 | [scripts] 24 | build = "pipenv run python -m build" 25 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "d812a365485fee1583ac0102ada099968f93c5923d8f13ece493e6cdbaab72c8" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.10" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiohttp": { 20 | "hashes": [ 21 | "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168", 22 | "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb", 23 | "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5", 24 | "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f", 25 | "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc", 26 | "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c", 27 | "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29", 28 | "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4", 29 | "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc", 30 | "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc", 31 | "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63", 32 | "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e", 33 | "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d", 34 | "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a", 35 | "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60", 36 | "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38", 37 | "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b", 38 | "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2", 39 | "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53", 40 | "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5", 41 | "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4", 42 | "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96", 43 | "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58", 44 | "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa", 45 | "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321", 46 | "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae", 47 | "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce", 48 | "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8", 49 | "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194", 50 | "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c", 51 | "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf", 52 | "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d", 53 | "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869", 54 | "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b", 55 | "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52", 56 | "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528", 57 | "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5", 58 | "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1", 59 | "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4", 60 | "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8", 61 | "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d", 62 | "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7", 63 | "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5", 64 | "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54", 65 | "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3", 66 | "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5", 67 | "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c", 68 | "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29", 69 | "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3", 70 | "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747", 71 | "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672", 72 | "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5", 73 | "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11", 74 | "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca", 75 | "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768", 76 | "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6", 77 | "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2", 78 | "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533", 79 | "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6", 80 | "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266", 81 | "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d", 82 | "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec", 83 | "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5", 84 | "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1", 85 | "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b", 86 | "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679", 87 | "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283", 88 | "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb", 89 | "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b", 90 | "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3", 91 | "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051", 92 | "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511", 93 | "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e", 94 | "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d", 95 | "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542", 96 | "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f" 97 | ], 98 | "markers": "python_version >= '3.8'", 99 | "version": "==3.9.3" 100 | }, 101 | "aiosignal": { 102 | "hashes": [ 103 | "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", 104 | "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" 105 | ], 106 | "markers": "python_version >= '3.7'", 107 | "version": "==1.3.1" 108 | }, 109 | "annotated-types": { 110 | "hashes": [ 111 | "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43", 112 | "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d" 113 | ], 114 | "markers": "python_version >= '3.8'", 115 | "version": "==0.6.0" 116 | }, 117 | "async-timeout": { 118 | "hashes": [ 119 | "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", 120 | "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" 121 | ], 122 | "markers": "python_version < '3.11'", 123 | "version": "==4.0.3" 124 | }, 125 | "attrs": { 126 | "hashes": [ 127 | "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", 128 | "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" 129 | ], 130 | "markers": "python_version >= '3.7'", 131 | "version": "==23.2.0" 132 | }, 133 | "click": { 134 | "hashes": [ 135 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 136 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 137 | ], 138 | "markers": "python_version >= '3.7'", 139 | "version": "==8.1.7" 140 | }, 141 | "frozenlist": { 142 | "hashes": [ 143 | "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7", 144 | "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98", 145 | "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad", 146 | "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5", 147 | "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae", 148 | "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e", 149 | "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a", 150 | "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701", 151 | "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d", 152 | "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6", 153 | "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6", 154 | "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106", 155 | "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75", 156 | "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868", 157 | "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a", 158 | "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0", 159 | "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1", 160 | "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826", 161 | "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec", 162 | "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6", 163 | "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950", 164 | "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19", 165 | "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0", 166 | "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8", 167 | "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a", 168 | "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09", 169 | "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86", 170 | "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c", 171 | "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5", 172 | "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b", 173 | "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b", 174 | "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d", 175 | "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0", 176 | "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea", 177 | "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776", 178 | "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a", 179 | "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897", 180 | "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7", 181 | "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09", 182 | "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9", 183 | "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe", 184 | "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd", 185 | "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742", 186 | "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09", 187 | "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0", 188 | "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932", 189 | "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1", 190 | "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a", 191 | "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49", 192 | "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d", 193 | "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7", 194 | "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480", 195 | "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89", 196 | "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e", 197 | "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b", 198 | "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82", 199 | "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb", 200 | "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068", 201 | "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8", 202 | "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b", 203 | "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb", 204 | "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2", 205 | "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11", 206 | "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b", 207 | "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc", 208 | "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0", 209 | "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497", 210 | "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17", 211 | "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0", 212 | "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2", 213 | "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439", 214 | "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5", 215 | "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac", 216 | "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825", 217 | "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887", 218 | "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced", 219 | "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74" 220 | ], 221 | "markers": "python_version >= '3.8'", 222 | "version": "==1.4.1" 223 | }, 224 | "idna": { 225 | "hashes": [ 226 | "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", 227 | "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" 228 | ], 229 | "markers": "python_version >= '3.5'", 230 | "version": "==3.6" 231 | }, 232 | "kostal-plenticore": { 233 | "editable": true, 234 | "extras": [ 235 | "cli" 236 | ], 237 | "path": "." 238 | }, 239 | "multidict": { 240 | "hashes": [ 241 | "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556", 242 | "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c", 243 | "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29", 244 | "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b", 245 | "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8", 246 | "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7", 247 | "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd", 248 | "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40", 249 | "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6", 250 | "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3", 251 | "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c", 252 | "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9", 253 | "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5", 254 | "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae", 255 | "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442", 256 | "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9", 257 | "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc", 258 | "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c", 259 | "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea", 260 | "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5", 261 | "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50", 262 | "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182", 263 | "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453", 264 | "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e", 265 | "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600", 266 | "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733", 267 | "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda", 268 | "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241", 269 | "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461", 270 | "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e", 271 | "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e", 272 | "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b", 273 | "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e", 274 | "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7", 275 | "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386", 276 | "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd", 277 | "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9", 278 | "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf", 279 | "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee", 280 | "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5", 281 | "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a", 282 | "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271", 283 | "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54", 284 | "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4", 285 | "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496", 286 | "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb", 287 | "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319", 288 | "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3", 289 | "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f", 290 | "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527", 291 | "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed", 292 | "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604", 293 | "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef", 294 | "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8", 295 | "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5", 296 | "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5", 297 | "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626", 298 | "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c", 299 | "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d", 300 | "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c", 301 | "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc", 302 | "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc", 303 | "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b", 304 | "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38", 305 | "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450", 306 | "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1", 307 | "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f", 308 | "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3", 309 | "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755", 310 | "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226", 311 | "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a", 312 | "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046", 313 | "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf", 314 | "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479", 315 | "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e", 316 | "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1", 317 | "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a", 318 | "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83", 319 | "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929", 320 | "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93", 321 | "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a", 322 | "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c", 323 | "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44", 324 | "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89", 325 | "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba", 326 | "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e", 327 | "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da", 328 | "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24", 329 | "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423", 330 | "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef" 331 | ], 332 | "markers": "python_version >= '3.7'", 333 | "version": "==6.0.5" 334 | }, 335 | "prompt-toolkit": { 336 | "hashes": [ 337 | "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d", 338 | "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6" 339 | ], 340 | "markers": "python_version >= '3.7'", 341 | "version": "==3.0.43" 342 | }, 343 | "pycryptodome": { 344 | "hashes": [ 345 | "sha256:06d6de87c19f967f03b4cf9b34e538ef46e99a337e9a61a77dbe44b2cbcf0690", 346 | "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7", 347 | "sha256:210ba1b647837bfc42dd5a813cdecb5b86193ae11a3f5d972b9a0ae2c7e9e4b4", 348 | "sha256:2a1250b7ea809f752b68e3e6f3fd946b5939a52eaeea18c73bdab53e9ba3c2dd", 349 | "sha256:2ab6ab0cb755154ad14e507d1df72de9897e99fd2d4922851a276ccc14f4f1a5", 350 | "sha256:3427d9e5310af6680678f4cce149f54e0bb4af60101c7f2c16fdf878b39ccccc", 351 | "sha256:3cd3ef3aee1079ae44afaeee13393cf68b1058f70576b11439483e34f93cf818", 352 | "sha256:405002eafad114a2f9a930f5db65feef7b53c4784495dd8758069b89baf68eab", 353 | "sha256:417a276aaa9cb3be91f9014e9d18d10e840a7a9b9a9be64a42f553c5b50b4d1d", 354 | "sha256:4401564ebf37dfde45d096974c7a159b52eeabd9969135f0426907db367a652a", 355 | "sha256:49a4c4dc60b78ec41d2afa392491d788c2e06edf48580fbfb0dd0f828af49d25", 356 | "sha256:5601c934c498cd267640b57569e73793cb9a83506f7c73a8ec57a516f5b0b091", 357 | "sha256:6e0e4a987d38cfc2e71b4a1b591bae4891eeabe5fa0f56154f576e26287bfdea", 358 | "sha256:76658f0d942051d12a9bd08ca1b6b34fd762a8ee4240984f7c06ddfb55eaf15a", 359 | "sha256:76cb39afede7055127e35a444c1c041d2e8d2f1f9c121ecef573757ba4cd2c3c", 360 | "sha256:8d6b98d0d83d21fb757a182d52940d028564efe8147baa9ce0f38d057104ae72", 361 | "sha256:9b3ae153c89a480a0ec402e23db8d8d84a3833b65fa4b15b81b83be9d637aab9", 362 | "sha256:a60fedd2b37b4cb11ccb5d0399efe26db9e0dd149016c1cc6c8161974ceac2d6", 363 | "sha256:ac1c7c0624a862f2e53438a15c9259d1655325fc2ec4392e66dc46cdae24d044", 364 | "sha256:acae12b9ede49f38eb0ef76fdec2df2e94aad85ae46ec85be3648a57f0a7db04", 365 | "sha256:acc2614e2e5346a4a4eab6e199203034924313626f9620b7b4b38e9ad74b7e0c", 366 | "sha256:acf6e43fa75aca2d33e93409f2dafe386fe051818ee79ee8a3e21de9caa2ac9e", 367 | "sha256:baee115a9ba6c5d2709a1e88ffe62b73ecc044852a925dcb67713a288c4ec70f", 368 | "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b", 369 | "sha256:d29daa681517f4bc318cd8a23af87e1f2a7bad2fe361e8aa29c77d652a065de4", 370 | "sha256:d5954acfe9e00bc83ed9f5cb082ed22c592fbbef86dc48b907238be64ead5c33", 371 | "sha256:ec0bb1188c1d13426039af8ffcb4dbe3aad1d7680c35a62d8eaf2a529b5d3d4f", 372 | "sha256:ec1f93feb3bb93380ab0ebf8b859e8e5678c0f010d2d78367cf6bc30bfeb148e", 373 | "sha256:f0e6d631bae3f231d3634f91ae4da7a960f7ff87f2865b2d2b831af1dfb04e9a", 374 | "sha256:f35d6cee81fa145333137009d9c8ba90951d7d77b67c79cbe5f03c7eb74d8fe2", 375 | "sha256:f47888542a0633baff535a04726948e876bf1ed880fddb7c10a736fa99146ab3", 376 | "sha256:fb3b87461fa35afa19c971b0a2b7456a7b1db7b4eba9a8424666104925b78128" 377 | ], 378 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 379 | "version": "==3.20.0" 380 | }, 381 | "pydantic": { 382 | "hashes": [ 383 | "sha256:1440966574e1b5b99cf75a13bec7b20e3512e8a61b894ae252f56275e2c465ae", 384 | "sha256:ae887bd94eb404b09d86e4d12f93893bdca79d766e738528c6fa1c849f3c6bcf" 385 | ], 386 | "markers": "python_version >= '3.8'", 387 | "version": "==2.6.0" 388 | }, 389 | "pydantic-core": { 390 | "hashes": [ 391 | "sha256:06f0d5a1d9e1b7932477c172cc720b3b23c18762ed7a8efa8398298a59d177c7", 392 | "sha256:07982b82d121ed3fc1c51faf6e8f57ff09b1325d2efccaa257dd8c0dd937acca", 393 | "sha256:0f478ec204772a5c8218e30eb813ca43e34005dff2eafa03931b3d8caef87d51", 394 | "sha256:102569d371fadc40d8f8598a59379c37ec60164315884467052830b28cc4e9da", 395 | "sha256:10dca874e35bb60ce4f9f6665bfbfad050dd7573596608aeb9e098621ac331dc", 396 | "sha256:150ba5c86f502c040b822777e2e519b5625b47813bd05f9273a8ed169c97d9ae", 397 | "sha256:1661c668c1bb67b7cec96914329d9ab66755911d093bb9063c4c8914188af6d4", 398 | "sha256:1a2fe7b00a49b51047334d84aafd7e39f80b7675cad0083678c58983662da89b", 399 | "sha256:1ae8048cba95f382dba56766525abca438328455e35c283bb202964f41a780b0", 400 | "sha256:20f724a023042588d0f4396bbbcf4cffd0ddd0ad3ed4f0d8e6d4ac4264bae81e", 401 | "sha256:2133b0e412a47868a358713287ff9f9a328879da547dc88be67481cdac529118", 402 | "sha256:21e3298486c4ea4e4d5cc6fb69e06fb02a4e22089304308817035ac006a7f506", 403 | "sha256:21ebaa4bf6386a3b22eec518da7d679c8363fb7fb70cf6972161e5542f470798", 404 | "sha256:23632132f1fd608034f1a56cc3e484be00854db845b3a4a508834be5a6435a6f", 405 | "sha256:2d5bea8012df5bb6dda1e67d0563ac50b7f64a5d5858348b5c8cb5043811c19d", 406 | "sha256:300616102fb71241ff477a2cbbc847321dbec49428434a2f17f37528721c4948", 407 | "sha256:30a8259569fbeec49cfac7fda3ec8123486ef1b729225222f0d41d5f840b476f", 408 | "sha256:399166f24c33a0c5759ecc4801f040dbc87d412c1a6d6292b2349b4c505effc9", 409 | "sha256:3fac641bbfa43d5a1bed99d28aa1fded1984d31c670a95aac1bf1d36ac6ce137", 410 | "sha256:42c29d54ed4501a30cd71015bf982fa95e4a60117b44e1a200290ce687d3e640", 411 | "sha256:462d599299c5971f03c676e2b63aa80fec5ebc572d89ce766cd11ca8bcb56f3f", 412 | "sha256:4eebbd049008eb800f519578e944b8dc8e0f7d59a5abb5924cc2d4ed3a1834ff", 413 | "sha256:502c062a18d84452858f8aea1e520e12a4d5228fc3621ea5061409d666ea1706", 414 | "sha256:5317c04349472e683803da262c781c42c5628a9be73f4750ac7d13040efb5d2d", 415 | "sha256:5511f962dd1b9b553e9534c3b9c6a4b0c9ded3d8c2be96e61d56f933feef9e1f", 416 | "sha256:561be4e3e952c2f9056fba5267b99be4ec2afadc27261505d4992c50b33c513c", 417 | "sha256:601d3e42452cd4f2891c13fa8c70366d71851c1593ed42f57bf37f40f7dca3c8", 418 | "sha256:644904600c15816a1f9a1bafa6aab0d21db2788abcdf4e2a77951280473f33e1", 419 | "sha256:653a5dfd00f601a0ed6654a8b877b18d65ac32c9d9997456e0ab240807be6cf7", 420 | "sha256:694a5e9f1f2c124a17ff2d0be613fd53ba0c26de588eb4bdab8bca855e550d95", 421 | "sha256:71b4a48a7427f14679f0015b13c712863d28bb1ab700bd11776a5368135c7d60", 422 | "sha256:72bf9308a82b75039b8c8edd2be2924c352eda5da14a920551a8b65d5ee89253", 423 | "sha256:735dceec50fa907a3c314b84ed609dec54b76a814aa14eb90da31d1d36873a5e", 424 | "sha256:73802194f10c394c2bedce7a135ba1d8ba6cff23adf4217612bfc5cf060de34c", 425 | "sha256:780daad9e35b18d10d7219d24bfb30148ca2afc309928e1d4d53de86822593dc", 426 | "sha256:8655f55fe68c4685673265a650ef71beb2d31871c049c8b80262026f23605ee3", 427 | "sha256:877045a7969ace04d59516d5d6a7dee13106822f99a5d8df5e6822941f7bedc8", 428 | "sha256:87bce04f09f0552b66fca0c4e10da78d17cb0e71c205864bab4e9595122cb9d9", 429 | "sha256:8d4dfc66abea3ec6d9f83e837a8f8a7d9d3a76d25c9911735c76d6745950e62c", 430 | "sha256:8ec364e280db4235389b5e1e6ee924723c693cbc98e9d28dc1767041ff9bc388", 431 | "sha256:8fa00fa24ffd8c31fac081bf7be7eb495be6d248db127f8776575a746fa55c95", 432 | "sha256:920c4897e55e2881db6a6da151198e5001552c3777cd42b8a4c2f72eedc2ee91", 433 | "sha256:920f4633bee43d7a2818e1a1a788906df5a17b7ab6fe411220ed92b42940f818", 434 | "sha256:9795f56aa6b2296f05ac79d8a424e94056730c0b860a62b0fdcfe6340b658cc8", 435 | "sha256:98f0edee7ee9cc7f9221af2e1b95bd02810e1c7a6d115cfd82698803d385b28f", 436 | "sha256:99c095457eea8550c9fa9a7a992e842aeae1429dab6b6b378710f62bfb70b394", 437 | "sha256:99d3a433ef5dc3021c9534a58a3686c88363c591974c16c54a01af7efd741f13", 438 | "sha256:99f9a50b56713a598d33bc23a9912224fc5d7f9f292444e6664236ae471ddf17", 439 | "sha256:9c46e556ee266ed3fb7b7a882b53df3c76b45e872fdab8d9cf49ae5e91147fd7", 440 | "sha256:9f5d37ff01edcbace53a402e80793640c25798fb7208f105d87a25e6fcc9ea06", 441 | "sha256:a0b4cfe408cd84c53bab7d83e4209458de676a6ec5e9c623ae914ce1cb79b96f", 442 | "sha256:a497be217818c318d93f07e14502ef93d44e6a20c72b04c530611e45e54c2196", 443 | "sha256:ac89ccc39cd1d556cc72d6752f252dc869dde41c7c936e86beac5eb555041b66", 444 | "sha256:adf28099d061a25fbcc6531febb7a091e027605385de9fe14dd6a97319d614cf", 445 | "sha256:afa01d25769af33a8dac0d905d5c7bb2d73c7c3d5161b2dd6f8b5b5eea6a3c4c", 446 | "sha256:b1fc07896fc1851558f532dffc8987e526b682ec73140886c831d773cef44b76", 447 | "sha256:b49c604ace7a7aa8af31196abbf8f2193be605db6739ed905ecaf62af31ccae0", 448 | "sha256:b9f3e0bffad6e238f7acc20c393c1ed8fab4371e3b3bc311020dfa6020d99212", 449 | "sha256:ba07646f35e4e49376c9831130039d1b478fbfa1215ae62ad62d2ee63cf9c18f", 450 | "sha256:bd88f40f2294440d3f3c6308e50d96a0d3d0973d6f1a5732875d10f569acef49", 451 | "sha256:c0be58529d43d38ae849a91932391eb93275a06b93b79a8ab828b012e916a206", 452 | "sha256:c45f62e4107ebd05166717ac58f6feb44471ed450d07fecd90e5f69d9bf03c48", 453 | "sha256:c56da23034fe66221f2208c813d8aa509eea34d97328ce2add56e219c3a9f41c", 454 | "sha256:c94b5537bf6ce66e4d7830c6993152940a188600f6ae044435287753044a8fe2", 455 | "sha256:cebf8d56fee3b08ad40d332a807ecccd4153d3f1ba8231e111d9759f02edfd05", 456 | "sha256:d0bf6f93a55d3fa7a079d811b29100b019784e2ee6bc06b0bb839538272a5610", 457 | "sha256:d195add190abccefc70ad0f9a0141ad7da53e16183048380e688b466702195dd", 458 | "sha256:d25ef0c33f22649b7a088035fd65ac1ce6464fa2876578df1adad9472f918a76", 459 | "sha256:d6cbdf12ef967a6aa401cf5cdf47850559e59eedad10e781471c960583f25aa1", 460 | "sha256:d8c032ccee90b37b44e05948b449a2d6baed7e614df3d3f47fe432c952c21b60", 461 | "sha256:daff04257b49ab7f4b3f73f98283d3dbb1a65bf3500d55c7beac3c66c310fe34", 462 | "sha256:e83ebbf020be727d6e0991c1b192a5c2e7113eb66e3def0cd0c62f9f266247e4", 463 | "sha256:ed3025a8a7e5a59817b7494686d449ebfbe301f3e757b852c8d0d1961d6be864", 464 | "sha256:f1936ef138bed2165dd8573aa65e3095ef7c2b6247faccd0e15186aabdda7f66", 465 | "sha256:f5247a3d74355f8b1d780d0f3b32a23dd9f6d3ff43ef2037c6dcd249f35ecf4c", 466 | "sha256:fa496cd45cda0165d597e9d6f01e36c33c9508f75cf03c0a650018c5048f578e", 467 | "sha256:fb4363e6c9fc87365c2bc777a1f585a22f2f56642501885ffc7942138499bf54", 468 | "sha256:fb4370b15111905bf8b5ba2129b926af9470f014cb0493a67d23e9d7a48348e8", 469 | "sha256:fbec2af0ebafa57eb82c18c304b37c86a8abddf7022955d1742b3d5471a6339e" 470 | ], 471 | "markers": "python_version >= '3.8'", 472 | "version": "==2.16.1" 473 | }, 474 | "pykoplenti": { 475 | "editable": true, 476 | "extras": [ 477 | "cli" 478 | ], 479 | "path": "." 480 | }, 481 | "typing-extensions": { 482 | "hashes": [ 483 | "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", 484 | "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" 485 | ], 486 | "markers": "python_version >= '3.8'", 487 | "version": "==4.9.0" 488 | }, 489 | "wcwidth": { 490 | "hashes": [ 491 | "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", 492 | "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" 493 | ], 494 | "version": "==0.2.13" 495 | }, 496 | "yarl": { 497 | "hashes": [ 498 | "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51", 499 | "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce", 500 | "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559", 501 | "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0", 502 | "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81", 503 | "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc", 504 | "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4", 505 | "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c", 506 | "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130", 507 | "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136", 508 | "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e", 509 | "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec", 510 | "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7", 511 | "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1", 512 | "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455", 513 | "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099", 514 | "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129", 515 | "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10", 516 | "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142", 517 | "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98", 518 | "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa", 519 | "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7", 520 | "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525", 521 | "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c", 522 | "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9", 523 | "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c", 524 | "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8", 525 | "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b", 526 | "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf", 527 | "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23", 528 | "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd", 529 | "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27", 530 | "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f", 531 | "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece", 532 | "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434", 533 | "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec", 534 | "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff", 535 | "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78", 536 | "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d", 537 | "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863", 538 | "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53", 539 | "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31", 540 | "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15", 541 | "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5", 542 | "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b", 543 | "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57", 544 | "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3", 545 | "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1", 546 | "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f", 547 | "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad", 548 | "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c", 549 | "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7", 550 | "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2", 551 | "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b", 552 | "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2", 553 | "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b", 554 | "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9", 555 | "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be", 556 | "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e", 557 | "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984", 558 | "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4", 559 | "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074", 560 | "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2", 561 | "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392", 562 | "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91", 563 | "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541", 564 | "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf", 565 | "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572", 566 | "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66", 567 | "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575", 568 | "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14", 569 | "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5", 570 | "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1", 571 | "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e", 572 | "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551", 573 | "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17", 574 | "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead", 575 | "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0", 576 | "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe", 577 | "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234", 578 | "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0", 579 | "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7", 580 | "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34", 581 | "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42", 582 | "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385", 583 | "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78", 584 | "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be", 585 | "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958", 586 | "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749", 587 | "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec" 588 | ], 589 | "markers": "python_version >= '3.7'", 590 | "version": "==1.9.4" 591 | } 592 | }, 593 | "develop": { 594 | "black": { 595 | "hashes": [ 596 | "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50", 597 | "sha256:1fa88a0f74e50e4487477bc0bb900c6781dbddfdfa32691e780bf854c3b4a47f", 598 | "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e", 599 | "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec", 600 | "sha256:3e1b38b3135fd4c025c28c55ddfc236b05af657828a8a6abe5deec419a0b7055", 601 | "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3", 602 | "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5", 603 | "sha256:4f0031eaa7b921db76decd73636ef3a12c942ed367d8c3841a0739412b260a54", 604 | "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b", 605 | "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e", 606 | "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e", 607 | "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba", 608 | "sha256:97e56155c6b737854e60a9ab1c598ff2533d57e7506d97af5481141671abf3ea", 609 | "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59", 610 | "sha256:a4d6a9668e45ad99d2f8ec70d5c8c04ef4f32f648ef39048d010b0689832ec6d", 611 | "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0", 612 | "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9", 613 | "sha256:b18fb2ae6c4bb63eebe5be6bd869ba2f14fd0259bda7d18a46b764d8fb86298a", 614 | "sha256:c04b6d9d20e9c13f43eee8ea87d44156b8505ca8a3c878773f68b4e4812a421e", 615 | "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba", 616 | "sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2", 617 | "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2" 618 | ], 619 | "index": "pypi", 620 | "version": "==23.12.1" 621 | }, 622 | "build": { 623 | "hashes": [ 624 | "sha256:538aab1b64f9828977f84bc63ae570b060a8ed1be419e7870b8b4fc5e6ea553b", 625 | "sha256:589bf99a67df7c9cf07ec0ac0e5e2ea5d4b37ac63301c4986d1acb126aa83f8f" 626 | ], 627 | "index": "pypi", 628 | "version": "==1.0.3" 629 | }, 630 | "cachetools": { 631 | "hashes": [ 632 | "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2", 633 | "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1" 634 | ], 635 | "markers": "python_version >= '3.7'", 636 | "version": "==5.3.2" 637 | }, 638 | "chardet": { 639 | "hashes": [ 640 | "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", 641 | "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" 642 | ], 643 | "markers": "python_version >= '3.7'", 644 | "version": "==5.2.0" 645 | }, 646 | "click": { 647 | "hashes": [ 648 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 649 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 650 | ], 651 | "markers": "python_version >= '3.7'", 652 | "version": "==8.1.7" 653 | }, 654 | "colorama": { 655 | "hashes": [ 656 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 657 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 658 | ], 659 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 660 | "version": "==0.4.6" 661 | }, 662 | "coverage": { 663 | "extras": [ 664 | "toml" 665 | ], 666 | "hashes": [ 667 | "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61", 668 | "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1", 669 | "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7", 670 | "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7", 671 | "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75", 672 | "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd", 673 | "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35", 674 | "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04", 675 | "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6", 676 | "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042", 677 | "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166", 678 | "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1", 679 | "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d", 680 | "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c", 681 | "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66", 682 | "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70", 683 | "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1", 684 | "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676", 685 | "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630", 686 | "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a", 687 | "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74", 688 | "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad", 689 | "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19", 690 | "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6", 691 | "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448", 692 | "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018", 693 | "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218", 694 | "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756", 695 | "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54", 696 | "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45", 697 | "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628", 698 | "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968", 699 | "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d", 700 | "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25", 701 | "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60", 702 | "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950", 703 | "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06", 704 | "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295", 705 | "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b", 706 | "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c", 707 | "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc", 708 | "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74", 709 | "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1", 710 | "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee", 711 | "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011", 712 | "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156", 713 | "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766", 714 | "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5", 715 | "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581", 716 | "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016", 717 | "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c", 718 | "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3" 719 | ], 720 | "markers": "python_full_version >= '3.8.0'", 721 | "version": "==7.4.1" 722 | }, 723 | "distlib": { 724 | "hashes": [ 725 | "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", 726 | "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64" 727 | ], 728 | "version": "==0.3.8" 729 | }, 730 | "exceptiongroup": { 731 | "hashes": [ 732 | "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", 733 | "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" 734 | ], 735 | "markers": "python_version < '3.11'", 736 | "version": "==1.2.0" 737 | }, 738 | "filelock": { 739 | "hashes": [ 740 | "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e", 741 | "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c" 742 | ], 743 | "markers": "python_full_version >= '3.8.0'", 744 | "version": "==3.13.1" 745 | }, 746 | "iniconfig": { 747 | "hashes": [ 748 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 749 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 750 | ], 751 | "markers": "python_version >= '3.7'", 752 | "version": "==2.0.0" 753 | }, 754 | "isort": { 755 | "hashes": [ 756 | "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", 757 | "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" 758 | ], 759 | "index": "pypi", 760 | "version": "==5.13.2" 761 | }, 762 | "mypy": { 763 | "hashes": [ 764 | "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6", 765 | "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d", 766 | "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02", 767 | "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d", 768 | "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3", 769 | "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3", 770 | "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3", 771 | "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66", 772 | "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259", 773 | "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835", 774 | "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd", 775 | "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d", 776 | "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8", 777 | "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07", 778 | "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b", 779 | "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e", 780 | "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6", 781 | "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae", 782 | "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9", 783 | "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d", 784 | "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a", 785 | "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592", 786 | "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218", 787 | "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817", 788 | "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4", 789 | "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410", 790 | "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55" 791 | ], 792 | "index": "pypi", 793 | "version": "==1.8.0" 794 | }, 795 | "mypy-extensions": { 796 | "hashes": [ 797 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 798 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 799 | ], 800 | "markers": "python_version >= '3.5'", 801 | "version": "==1.0.0" 802 | }, 803 | "packaging": { 804 | "hashes": [ 805 | "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", 806 | "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" 807 | ], 808 | "markers": "python_version >= '3.7'", 809 | "version": "==23.2" 810 | }, 811 | "pathspec": { 812 | "hashes": [ 813 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 814 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 815 | ], 816 | "markers": "python_full_version >= '3.8.0'", 817 | "version": "==0.12.1" 818 | }, 819 | "platformdirs": { 820 | "hashes": [ 821 | "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", 822 | "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" 823 | ], 824 | "markers": "python_full_version >= '3.8.0'", 825 | "version": "==4.2.0" 826 | }, 827 | "pluggy": { 828 | "hashes": [ 829 | "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", 830 | "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" 831 | ], 832 | "markers": "python_full_version >= '3.8.0'", 833 | "version": "==1.4.0" 834 | }, 835 | "pyproject-api": { 836 | "hashes": [ 837 | "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538", 838 | "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675" 839 | ], 840 | "markers": "python_full_version >= '3.8.0'", 841 | "version": "==1.6.1" 842 | }, 843 | "pyproject-hooks": { 844 | "hashes": [ 845 | "sha256:283c11acd6b928d2f6a7c73fa0d01cb2bdc5f07c57a2eeb6e83d5e56b97976f8", 846 | "sha256:f271b298b97f5955d53fb12b72c1fb1948c22c1a6b70b315c54cedaca0264ef5" 847 | ], 848 | "markers": "python_version >= '3.7'", 849 | "version": "==1.0.0" 850 | }, 851 | "pytest": { 852 | "hashes": [ 853 | "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", 854 | "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8" 855 | ], 856 | "index": "pypi", 857 | "version": "==7.4.4" 858 | }, 859 | "pytest-asyncio": { 860 | "hashes": [ 861 | "sha256:2143d9d9375bf372a73260e4114541485e84fca350b0b6b92674ca56ff5f7ea2", 862 | "sha256:b0079dfac14b60cd1ce4691fbfb1748fe939db7d0234b5aba97197d10fbe0fef" 863 | ], 864 | "index": "pypi", 865 | "version": "==0.23.4" 866 | }, 867 | "pytest-cov": { 868 | "hashes": [ 869 | "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", 870 | "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" 871 | ], 872 | "index": "pypi", 873 | "version": "==4.1.0" 874 | }, 875 | "ruff": { 876 | "hashes": [ 877 | "sha256:30ad74687e1f4a9ff8e513b20b82ccadb6bd796fe5697f1e417189c5cde6be3e", 878 | "sha256:3826fb34c144ef1e171b323ed6ae9146ab76d109960addca730756dc19dc7b22", 879 | "sha256:3d3c641f95f435fc6754b05591774a17df41648f0daf3de0d75ad3d9f099ab92", 880 | "sha256:3fbaff1ba9564a2c5943f8f38bc221f04bac687cc7485e45237579fee7ccda79", 881 | "sha256:3ff35433fcf4dff6d610738712152df6b7d92351a1bde8e00bd405b08b3d5759", 882 | "sha256:63856b91837606c673537d2889989733d7dffde553828d3b0f0bacfa6def54be", 883 | "sha256:638ea3294f800d18bae84a492cb5a245c8d29c90d19a91d8e338937a4c27fca0", 884 | "sha256:6d232f99d3ab00094ebaf88e0fb7a8ccacaa54cc7fa3b8993d9627a11e6aed7a", 885 | "sha256:8153a3e4128ed770871c47545f1ae7b055023e0c222ff72a759f5a341ee06483", 886 | "sha256:87057dd2fdde297130ff99553be8549ca38a2965871462a97394c22ed2dfc19d", 887 | "sha256:a7e3818698f8460bd0f8d4322bbe99db8327e9bc2c93c789d3159f5b335f47da", 888 | "sha256:ba918e01cdd21e81b07555564f40d307b0caafa9a7a65742e98ff244f5035c59", 889 | "sha256:bf9faafbdcf4f53917019f2c230766da437d4fd5caecd12ddb68bb6a17d74399", 890 | "sha256:e155147199c2714ff52385b760fe242bb99ea64b240a9ffbd6a5918eb1268843", 891 | "sha256:e8a75a98ae989a27090e9c51f763990ad5bbc92d20626d54e9701c7fe597f399", 892 | "sha256:eceab7d85d09321b4de18b62d38710cf296cb49e98979960a59c6b9307c18cfe", 893 | "sha256:edf23041242c48b0d8295214783ef543847ef29e8226d9f69bf96592dba82a83" 894 | ], 895 | "index": "pypi", 896 | "version": "==0.2.0" 897 | }, 898 | "tomli": { 899 | "hashes": [ 900 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 901 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 902 | ], 903 | "markers": "python_version < '3.11'", 904 | "version": "==2.0.1" 905 | }, 906 | "tox": { 907 | "hashes": [ 908 | "sha256:61aafbeff1bd8a5af84e54ef6e8402f53c6a6066d0782336171ddfbf5362122e", 909 | "sha256:c07ea797880a44f3c4f200ad88ad92b446b83079d4ccef89585df64cc574375c" 910 | ], 911 | "index": "pypi", 912 | "version": "==4.12.1" 913 | }, 914 | "typing-extensions": { 915 | "hashes": [ 916 | "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", 917 | "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" 918 | ], 919 | "markers": "python_version >= '3.8'", 920 | "version": "==4.9.0" 921 | }, 922 | "virtualenv": { 923 | "hashes": [ 924 | "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3", 925 | "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b" 926 | ], 927 | "markers": "python_version >= '3.7'", 928 | "version": "==20.25.0" 929 | } 930 | } 931 | } 932 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Library for Accessing Kostal Plenticore Inverters 2 | 3 | This repository provides a python library and command line interface for the REST-API of Kostal Plenticore Solar Inverter. 4 | 5 | This library is not affiliated with Kostal and is no offical product. It uses the interfaces of the inverter like other libs (eg. https://github.com/kilianknoll/kostal-RESTAPI) and uses information from their swagger documentation (ip-addr/api/v1/). 6 | 7 | ![CI](https://github.com/stegm/pykoplenti/workflows/CI/badge.svg) 8 | 9 | ## Features 10 | 11 | - Authenticate 12 | - Read/Write settings 13 | - Read process data 14 | - Read events 15 | - Download of log data 16 | - Full async-Support for reading and writing data 17 | - [Commandline interface](doc/command_line.md) for shell access 18 | - Dynamic data model - adapts automatically to new process data or settings 19 | - [Virtual Process Data](doc/virtual_process_data.md) values 20 | 21 | ## Getting Started 22 | 23 | ### Prerequisites 24 | 25 | You will need Python >=3.7. 26 | 27 | ### Installing the library 28 | 29 | Packages of this library are released on [PyPI](https://pypi.org/project/kostal-plenticore/) and can be 30 | installed with `pip`. Alternatively the packages can also be downloaded from 31 | [GitHub](https://github.com/stegm/pykoplenti/releases/). 32 | 33 | I recommend to use a [virtual environment](https://docs.python.org/3/library/venv.html) for this, 34 | because it installs the dependecies independently from the system. The installed CLI tools can then be called 35 | without activating the virtual environment it. 36 | 37 | ```shell 38 | # Install with command line support 39 | $ pip install pykoplenti[CLI] 40 | 41 | # Install without command line support 42 | $ pip install pykoplenti 43 | ``` 44 | 45 | ### Using the command line interface 46 | 47 | Installing the libray with `CLI` provides a new command. 48 | 49 | ```shell 50 | $ pykoplenti --help 51 | Usage: python -m pykoplenti.cli [OPTIONS] COMMAND [ARGS]... 52 | 53 | Handling of global arguments with click 54 | 55 | Options: 56 | --host TEXT Hostname or IP of the inverter 57 | --port INTEGER Port of the inverter [default: 80] 58 | --password TEXT Password or master key (also device id) 59 | --service-code TEXT service code for installer access 60 | --password-file FILE Path to password file - deprecated, use --credentials 61 | [default: secrets] 62 | --credentials FILE Path to the credentials file. This has a simple ini- 63 | format without sections. For user access, use the 64 | 'password'. For installer access, use the 'master-key' 65 | and 'service-key'. 66 | --help Show this message and exit. 67 | 68 | Commands: 69 | all-processdata Returns a list of all available process data. 70 | all-settings Returns the ids of all settings. 71 | download-log Download the log data from the inverter to a file. 72 | read-events Returns the last events 73 | read-processdata Returns the values of the given process data. 74 | read-settings Read the value of the given settings. 75 | repl Provides a simple REPL for executing API requests to... 76 | write-settings Write the values of the given settings. 77 | ``` 78 | 79 | Visit [Command Line Help](doc/command_line.md) for example usage. 80 | 81 | ### Using the library from python 82 | 83 | The library is fully async, there for you need an async loop and an async `ClientSession`. Please refer to the 84 | example directory for full code. 85 | 86 | Import the client module: 87 | 88 | ```python 89 | from pykoplenti import ApiClient 90 | ``` 91 | 92 | To communicate with the inverter you need to instantiate the client: 93 | 94 | ```python 95 | # session is a aiohttp ClientSession 96 | client = ApiClient(session, '192.168.1.100') 97 | ``` 98 | 99 | Login to gain full access to process data and settings: 100 | 101 | ```python 102 | await client.login(passwd) 103 | ``` 104 | 105 | Now you can access the API. For example to read process data values: 106 | 107 | ```python 108 | data = await client.get_process_data_values('devices:local', ['Inverter:State', 'Home_P']) 109 | 110 | device_local = data['devices:local'] 111 | inverter_state = device_local['Inverter:State'] 112 | home_p = device_local['Home_P'] 113 | ``` 114 | 115 | See the full example here: [read_process_data.py](examples/read_process_data.py). 116 | 117 | If you should need installer access use the master key (printed on a label at the side of the inverter) 118 | and additionally pass your service code: 119 | 120 | ```python 121 | await client.login(my_master_key, service_code=my_service_code) 122 | ``` 123 | 124 | ## Documentation 125 | 126 | - [Command Line Interface](doc/command_line.md) 127 | - [Examples](examples/) 128 | - [Virtual Process Data](doc/virtual_process_data.md) 129 | - [Notes about Process Data](doc/process_data.md) 130 | 131 | ## Built With 132 | 133 | - [AIOHTTPO](https://docs.aiohttp.org/en/stable/) - asyncio for HTTP 134 | - [click](https://click.palletsprojects.com/) - command line interface framework 135 | - [black](https://github.com/psf/black) - Python code formatter 136 | - [ruff](https://github.com/astral-sh/ruff) - Python linter 137 | - [pydantic](https://docs.pydantic.dev/latest/) - Data validation library 138 | - [pytest](https://docs.pytest.org/) - Python test framework 139 | - [mypy](https://mypy-lang.org/) - Python type checker 140 | - [setuptools](https://github.com/pypa/setuptools) - Python packager 141 | - [tox](https://tox.wiki) - Automate testing 142 | 143 | ## License 144 | 145 | apache-2.0 146 | 147 | ## Acknowledgments 148 | 149 | - [kilianknoll](https://github.com/kilianknoll) for the kostal-RESTAPI project 150 | -------------------------------------------------------------------------------- /doc/command_line.md: -------------------------------------------------------------------------------- 1 | # Command Line Interface 2 | 3 | ## Shell-Commands 4 | 5 | The hostname or IP of the Plenticore inverter must be given as argument `--host`. The password might be given direct on the command line with the `--password` option or by file with `--credentials` (`--password-file` is deprecated). 6 | 7 | The credentials file is a text file containing at least the following line: 8 | 9 | ``` 10 | password= 11 | ``` 12 | 13 | If you want to use installer authentication instead, the file should contain two lines: 14 | 15 | ``` 16 | master-key= 17 | service-code= 18 | ``` 19 | 20 | Alternatively, `--password` and `--service-code` arguments can be used. 21 | 22 | After the first login a session id is created and saved in a temporary file. If the command is executed a second time, it is first checked if the session ID is still valid. If not, a new logon attempt is made. 23 | 24 | ### Display all available process data id's 25 | 26 | ```shell script 27 | $ pykoplenti --host 192.168.1.100 --password verysecret all-processdata 28 | devices:local/Dc_P 29 | devices:local/DigitalIn 30 | devices:local/EM_State 31 | devices:local/Grid_L1_I 32 | devices:local/Grid_L1_P 33 | ~~~ 34 | scb:statistic:EnergyFlow/Statistic:Yield:Month 35 | scb:statistic:EnergyFlow/Statistic:Yield:Total 36 | scb:statistic:EnergyFlow/Statistic:Yield:Year 37 | ``` 38 | 39 | The returned ids can be used to query process data values. 40 | 41 | ### Read process data values 42 | 43 | **Read a single value** 44 | 45 | ```shell script 46 | $ pykoplenti --host 192.168.1.100 --password verysecret read-processdata devices:local/Inverter:State 47 | devices:local/Inverter:State=6.0 48 | ``` 49 | 50 | **Read multiple values (even on different modules)** 51 | 52 | ```shell script 53 | $ pykoplenti --host 192.168.1.100 --password verysecret read-processdata devices:local/Inverter:State devices:local/EM_State devices:local:pv1/U 54 | devices:local/EM_State=0.0 55 | devices:local/Inverter:State=6.0 56 | devices:local:pv1/U=11.0961999893 57 | ``` 58 | 59 | This is the most efficient way because all process data are fetched with a single HTTP request. 60 | 61 | **Read all values off a module** 62 | 63 | ```shell script 64 | $ pykoplenti --host 192.168.1.100 --password verysecret read-processdata devices:local:pv1 65 | devices:local:pv1/I=0.0058542006 66 | devices:local:pv1/P=-0.11253988 67 | devices:local:pv1/U=10.9401073456 68 | ``` 69 | 70 | ### Display all available setting id's 71 | 72 | **Display all setting id's** 73 | 74 | ```shell script 75 | $ pykoplenti --host 192.168.1.100 --password verysecret all-settings 76 | devices:local/ActivePower:ExtCtrl:Enable 77 | devices:local/ActivePower:ExtCtrl:ModeGradientEnable 78 | devices:local/ActivePower:ExtCtrl:ModeGradientFactor 79 | ~~~ 80 | scb:time/NTPservers 81 | scb:time/NTPuse 82 | scb:time/Timezone 83 | ``` 84 | 85 | **Display only writable setting id's** 86 | 87 | ```shell script 88 | $ pykoplenti --host 192.168.1.100 --password verysecret all-settings --rw 89 | devices:local/Battery:BackupMode:Enable 90 | devices:local/Battery:DynamicSoc:Enable 91 | devices:local/Battery:MinHomeComsumption 92 | ~~~ 93 | scb:time/NTPservers 94 | scb:time/NTPuse 95 | scb:time/Timezone 96 | ``` 97 | 98 | ### Reading setting values 99 | 100 | **Read a single setting value** 101 | 102 | ```shell script 103 | $ pykoplenti --host 192.168.1.100 --password verysecret read-settings scb:time/Timezone 104 | scb:time/Timezone=Europe/Berlin 105 | ``` 106 | 107 | **Read multiple setting values** 108 | ```shell script 109 | $ pykoplenti --host 192.168.1.100 --password verysecret read-settings scb:time/Timezone scb:network/Hostname 110 | scb:time/Timezone=Europe/Berlin 111 | scb:network/Hostname=scb 112 | ``` 113 | 114 | ### Writing setting values 115 | 116 | ```shell script 117 | $ pykoplenti --host 192.168.1.100 --password verysecret write-settings devices:local/Battery:MinSoc=10 118 | ``` 119 | 120 | ### REPL 121 | 122 | A REPL is provided for simple interactive tests. All methods of the `ApiClient` class can be called. The 123 | arguments must be given separated by spaces by using python literals. 124 | 125 | ```shell script 126 | $ pykoplenti --host 192.168.1.100 repl 127 | (pykoplenti)> get_me 128 | Me(locked=False, active=False, authenticated=False, permissions=[] anonymous=True role=NONE) 129 | (pykoplenti)> get_process_data_values "devices:local" "Inverter:State" 130 | devices:local: 131 | ProcessData(id=Inverter:State, unit=, value=6.0) 132 | ``` -------------------------------------------------------------------------------- /doc/developer.md: -------------------------------------------------------------------------------- 1 | # Developer Notes 2 | 3 | ## Code Format 4 | 5 | ```shell script 6 | isort pykoplenti 7 | black --fast pykoplenti 8 | ``` 9 | 10 | ## Initialize developer environment with pipenv 11 | 12 | ```shell script 13 | pipenv sync --dev 14 | ``` 15 | 16 | ## Run pytest using tox 17 | 18 | `tox` is configured to run pytest with different versions of pydantic. 19 | 20 | Run all environemnts: 21 | 22 | ```shell script 23 | tox 24 | ``` 25 | 26 | Available environments: 27 | 28 | * `py39-pydantic1` - Python 3.9 with Pydantic 1.x 29 | * `py39-pydantic2` - Python 3.9 with Pydantic 2.x 30 | * `py310-pydantic1` - Python 3.10 with Pydantic 1.x 31 | * `py310-pydantic2` - Python 3.10 with Pydantic 2.x 32 | * `py311-pydantic1` - Python 3.11 with Pydantic 1.x 33 | * `py311-pydantic2` - Python 3.11 with Pydantic 2.x 34 | * `py312-pydantic1` - Python 3.12 with Pydantic 1.x 35 | * `py312-pydantic2` - Python 3.12 with Pydantic 2.x 36 | 37 | If `tox` should use `pyenv`, the package `tox-pyenv-redux` must be installed manually. 38 | It cannot be installed in pipenv dev, because it is incompatible with github actions. 39 | 40 | ## Running smoke tests 41 | 42 | The test suite contains some smoke tests that connect directly to an inverter and attempt to retrieve data from it. 43 | These tests are normally disabled but can be enabled by setting some environment variables before running `pytest`. 44 | It is recommended to set these variables in `.env` where `pipenv` reads them before executing a command. 45 | 46 | | Variable | Description | 47 | | ---------------- | ----------------------------------------------------- | 48 | | SMOKETEST_HOST | The ip or host of the inverter. | 49 | | SMOKETEST_PORT | The port of the web API of the inverter (default: 80) | 50 | | SMOKETEST_PASS | The password of the web UI | 51 | -------------------------------------------------------------------------------- /doc/process_data.md: -------------------------------------------------------------------------------- 1 | # Process Data 2 | 3 | Here are some notes about process data which might not be clear at first: 4 | 5 | | Process Data | Modbus | Description | Comment | 6 | |--------------|--------|-------------|---------| 7 | | devices:local/Dc_P | 0x64 | Total DC power | This also includes battery | 8 | | scb:statistic:EnergyFlow/Statistic:EnergyChargePv:Total | 0x416 | Total DC charge energy (DC-side to battery) | | 9 | | scb:statistic:EnergyFlow/Statistic:EnergyDischarge:Total | 0x418 | Total DC discharge energy (DC-side from battery) | | 10 | | scb:statistic:EnergyFlow/Statistic:EnergyChargeGrid:Total | 0x41A | Total AC charge energy (AC-side to battery) | | 11 | | scb:statistic:EnergyFlow/Statistic:EnergyDischargeGrid:Total | 0x41C | Total AC discharge energy (battery to grid) | | 12 | | scb:statistic:EnergyFlow/Statistic:EnergyChargeInvIn:Total | 0x41E | Total AC charge energy (grid to battery) | | 13 | -------------------------------------------------------------------------------- /doc/virtual_process_data.md: -------------------------------------------------------------------------------- 1 | # Virtual Process Data 2 | 3 | Currently the inverter API is missing some interessting values, which are now provided by the class `ExtendedApiClient`. These virtual items are computed by means of other process items. 4 | 5 | All virtual process items are in the module `_virt_`. 6 | 7 | Note: This feature is experimental and might change in the next version. 8 | 9 | | process id | description | 10 | |-----------------------------|-------------| 11 | | pv_P | Sum of all PV DC inputs (from `devices:local:pv1/P` + `devices:local:pv2/P` + `devices:local:pv3/P`) | 12 | | Statistic:EnergyGrid:Total | Total energy delivered to grid (from `Statistic:Yield:Total` - `Statistic:EnergyHomeBat:Total` - `Statistic:EnergyHomePv:Total`) | 13 | | Statistic:EnergyGrid:Year | Total energy delivered to grid (from `Statistic:Yield:Year` - `Statistic:EnergyHomeBat:Year` - `Statistic:EnergyHomePv:Year`) | 14 | | Statistic:EnergyGrid:Month | Total energy delivered to grid (from `Statistic:Yield:Month` - `Statistic:EnergyHomeBat:Month` - `Statistic:EnergyHomePv:Month`) | 15 | | Statistic:EnergyGrid:Day | Total energy delivered to grid (from `Statistic:Yield:Day` - `Statistic:EnergyHomeBat:Day` - `Statistic:EnergyHomePv:Day`) | 16 | -------------------------------------------------------------------------------- /examples/read_process_data.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | from aiohttp import ClientSession 5 | 6 | from pykoplenti import ApiClient 7 | 8 | """ 9 | Provides a simple example which reads two process data from the plenticore. 10 | 11 | Must be called with host and password: 12 | `python read_process_data.py 192.168.1.100 mysecretpassword` 13 | 14 | """ 15 | 16 | 17 | async def async_main(host, passwd): 18 | async with ClientSession() as session: 19 | client = ApiClient(session, host) 20 | await client.login(passwd) 21 | 22 | data = await client.get_process_data_values( 23 | "devices:local", ["Inverter:State", "Home_P"] 24 | ) 25 | 26 | device_local = data["devices:local"] 27 | inverter_state = device_local["Inverter:State"] 28 | home_p = device_local["Home_P"] 29 | 30 | print(f"Inverter-State: {inverter_state.value}\nHome-P: {home_p.value}\n") 31 | 32 | 33 | if len(sys.argv) != 3: 34 | print("Usage: ") 35 | sys.exit(1) 36 | 37 | _, host, passwd = sys.argv 38 | 39 | asyncio.run(async_main(host, passwd)) 40 | -------------------------------------------------------------------------------- /examples/read_virtual_process_data.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | from aiohttp import ClientSession 5 | 6 | from pykoplenti import ExtendedApiClient 7 | 8 | """ 9 | Provides a simple example which reads virtual process data from the plenticore. 10 | 11 | Must be called with host and password: 12 | `python read_virtual_process_data.py 192.168.1.100 mysecretpassword` 13 | 14 | """ 15 | 16 | 17 | async def async_main(host, passwd): 18 | async with ClientSession() as session: 19 | client = ExtendedApiClient(session, host) 20 | await client.login(passwd) 21 | 22 | data = await client.get_process_data_values("_virt_", "pv_P") 23 | 24 | pv_power = data["_virt_"]["pv_P"] 25 | 26 | print(f"PV power: {pv_power}") 27 | 28 | 29 | if len(sys.argv) != 3: 30 | print("Usage: ") 31 | sys.exit(1) 32 | 33 | _, host, passwd = sys.argv 34 | 35 | asyncio.run(async_main(host, passwd)) 36 | -------------------------------------------------------------------------------- /pykoplenti/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import ( 2 | ApiClient, 3 | ApiException, 4 | AuthenticationException, 5 | InternalCommunicationException, 6 | ModuleNotFoundException, 7 | NotAuthorizedException, 8 | UserLockedException, 9 | ) 10 | from .extended import ExtendedApiClient 11 | from .model import ( 12 | EventData, 13 | MeData, 14 | ModuleData, 15 | ProcessData, 16 | ProcessDataCollection, 17 | SettingsData, 18 | VersionData, 19 | ) 20 | 21 | __all__ = [ 22 | "MeData", 23 | "VersionData", 24 | "ModuleData", 25 | "ProcessData", 26 | "ProcessDataCollection", 27 | "SettingsData", 28 | "EventData", 29 | "ApiException", 30 | "InternalCommunicationException", 31 | "AuthenticationException", 32 | "NotAuthorizedException", 33 | "UserLockedException", 34 | "ModuleNotFoundException", 35 | "ApiClient", 36 | "ExtendedApiClient", 37 | ] 38 | -------------------------------------------------------------------------------- /pykoplenti/api.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode, b64encode 2 | from collections.abc import Mapping 3 | import contextlib 4 | from datetime import datetime 5 | import functools 6 | import hashlib 7 | import hmac 8 | import locale 9 | import logging 10 | from os import urandom 11 | from typing import IO, Dict, Final, Iterable, List, Union, overload 12 | import warnings 13 | 14 | from Crypto.Cipher import AES 15 | from aiohttp import ClientResponse, ClientSession, ClientTimeout 16 | from yarl import URL 17 | 18 | from .model import ( 19 | EventData, 20 | MeData, 21 | ModuleData, 22 | ProcessDataCollection, 23 | SettingsData, 24 | VersionData, 25 | process_data_list, 26 | ) 27 | 28 | _logger: Final = logging.getLogger(__name__) 29 | 30 | 31 | class ApiException(Exception): 32 | """Base exception for API calls.""" 33 | 34 | def __init__(self, msg): 35 | self.msg = msg 36 | 37 | def __str__(self): 38 | return f"API Error: {self.msg}" 39 | 40 | 41 | class InternalCommunicationException(ApiException): 42 | """Exception for internal communication error response.""" 43 | 44 | def __init__(self, status_code: int, error: str): 45 | super().__init__(f"Internal communication error ([{status_code}] - {error})") 46 | self.status_code = status_code 47 | self.error = error 48 | 49 | 50 | class AuthenticationException(ApiException): 51 | """Exception for authentication or user error response.""" 52 | 53 | def __init__(self, status_code: int, error: str): 54 | super().__init__( 55 | f"Invalid user/Authentication failed ([{status_code}] - {error})" 56 | ) 57 | self.status_code = status_code 58 | self.error = error 59 | 60 | 61 | class NotAuthorizedException(ApiException): 62 | """Exception for calles without authentication.""" 63 | 64 | def __init__(self, status_code: int, error: str): 65 | super().__init__(f"Not authorized ([{status_code}] - {error})") 66 | self.status_code = status_code 67 | self.error = error 68 | 69 | 70 | class UserLockedException(ApiException): 71 | """Exception for user locked error response.""" 72 | 73 | def __init__(self, status_code: int, error: str): 74 | super().__init__(f"User is locked ([{status_code}] - {error})") 75 | self.status_code = status_code 76 | self.error = error 77 | 78 | 79 | class ModuleNotFoundException(ApiException): 80 | """Exception for module or setting not found response.""" 81 | 82 | def __init__(self, status_code: int, error: str): 83 | super().__init__(f"Module or setting not found ([{status_code}] - {error})") 84 | self.status_code = status_code 85 | self.error = error 86 | 87 | 88 | def _relogin(fn): 89 | """Decorator for automatic re-login if session was expired.""" 90 | 91 | @functools.wraps(fn) 92 | async def _wrapper(self: "ApiClient", *args, **kwargs): 93 | with contextlib.suppress(AuthenticationException, NotAuthorizedException): 94 | return await fn(self, *args, **kwargs) 95 | _logger.debug("Request failed - try to re-login") 96 | await self._login() 97 | return await fn(self, *args, **kwargs) 98 | 99 | return _wrapper 100 | 101 | 102 | class ApiClient(contextlib.AbstractAsyncContextManager): 103 | """Client for the REST-API of Kostal Plenticore inverters. 104 | 105 | The RESP-API provides several scopes of information. Each scope provides a 106 | dynamic set of data which can be retrieved using this interface. The scopes 107 | are: 108 | 109 | - process data (readonly, dynamic values of the operation) 110 | - settings (some are writable, static values for configuration) 111 | 112 | The data are grouped into modules. For example the module `devices:local` 113 | provides a process data `Dc_P` which contains the value of the current 114 | DC power. 115 | 116 | To get all process data or settings the methods `get_process_data` or 117 | `get_settings` can be used. Depending of the current logged in user the 118 | returned data can vary. 119 | 120 | The methods `get_process_data_values` and `get_setting_values` can be used 121 | to read process data or setting values from the inverter. You can use 122 | `set_setting_values` to write new setting values to the inverter if the 123 | setting is writable. 124 | 125 | The authorization system of the inverter comprises three states: 126 | * not logged in (is_active=False, authenticated=False) 127 | * logged in and active (is_active=True, authenticated=True) 128 | * logged in and inactive (is_active=False, authenticated=False) 129 | 130 | The current state can be queried with the `get_me` method. Depending of 131 | this state some operation might not be available. 132 | """ 133 | 134 | BASE_URL = "/api/v1/" 135 | SUPPORTED_LANGUAGES = { 136 | "de": ["de"], 137 | "en": ["gb"], 138 | "es": ["es"], 139 | "fr": ["fr"], 140 | "hu": ["hu"], 141 | "it": ["it"], 142 | "nl": ["nl"], 143 | "pl": ["pl"], 144 | "pt": ["pt"], 145 | "cs": ["cz"], 146 | "el": ["gr"], 147 | "zh": ["cn"], 148 | } 149 | 150 | def __init__(self, websession: ClientSession, host: str, port: int = 80): 151 | """Create a new client. 152 | 153 | :param websession: A aiohttp ClientSession for all requests 154 | :param host: The hostname or ip of the inverter 155 | :param port: The port of the API interface (default 80) 156 | """ 157 | self.websession = websession 158 | self.host = host 159 | self.port = port 160 | self.session_id: Union[str, None] = None 161 | self._key: Union[str, None] = None 162 | self._service_code: Union[str, None] = None 163 | self._user: Union[str, None] = None 164 | 165 | async def __aexit__(self, exc_type, exc_value, traceback): 166 | """Logout support for context manager.""" 167 | if self.session_id is not None: 168 | await self.logout() 169 | 170 | def _create_url(self, path: str) -> URL: 171 | """Creates a REST-API URL with the given path as suffix. 172 | 173 | :param path: path suffix, must not start with '/' 174 | :return: a URL instance 175 | """ 176 | base = URL.build( 177 | scheme="http", 178 | host=self.host, 179 | port=self.port, 180 | path=ApiClient.BASE_URL, 181 | ) 182 | return base.join(URL(path)) 183 | 184 | async def initialize_virtual_process_data(self): 185 | process_data = await self.get_process_data() 186 | self._virt_process_data.initialize(process_data) 187 | 188 | async def login( 189 | self, 190 | key: str, 191 | service_code: Union[str, None] = None, 192 | password: Union[str, None] = None, 193 | user: Union[str, None] = None, 194 | ): 195 | """Login with the given password (key). 196 | 197 | If a service code is provided user is 'master', else 'user'. 198 | 199 | Parameters 200 | ---------- 201 | :param key: The user password. If 'service_code' is given, 'key' is the 202 | Master Key (also called Device ID). 203 | :type key: str, None 204 | :param service_code: Installer service code. If given the user is assumed to be 205 | 'master', else 'user'. 206 | :type service_code: str, None 207 | :param password: Deprecated, use key instead. 208 | :param user: Deprecated, user is chosen automatically depending on service_code. 209 | 210 | :raises AuthenticationException: if authentication failed 211 | :raises aiohttp.client_exceptions.ClientConnectorError: if host is not reachable 212 | :raises asyncio.exceptions.TimeoutError: if a timeout occurs 213 | """ 214 | 215 | if password is None: 216 | self._key = key 217 | else: 218 | warnings.warn( 219 | "password is deprecated. Use key instead.", DeprecationWarning 220 | ) 221 | self._key = password 222 | 223 | if user is None: 224 | self._user = "master" if service_code else "user" 225 | else: 226 | warnings.warn( 227 | "user is deprecated. user is chosen automatically.", DeprecationWarning 228 | ) 229 | 230 | self._service_code = service_code 231 | 232 | try: 233 | await self._login() 234 | except Exception: 235 | self._key = None 236 | self._user = None 237 | self._service_code = None 238 | raise 239 | 240 | async def _login(self): 241 | # Step 1 start authentication 242 | client_nonce = urandom(12) 243 | 244 | start_request = { 245 | "username": self._user, 246 | "nonce": b64encode(client_nonce).decode("utf-8"), 247 | } 248 | 249 | async with self.websession.request( 250 | "POST", self._create_url("auth/start"), json=start_request 251 | ) as resp: 252 | await self._check_response(resp) 253 | start_response = await resp.json() 254 | server_nonce = b64decode(start_response["nonce"]) 255 | transaction_id = b64decode(start_response["transactionId"]) 256 | salt = b64decode(start_response["salt"]) 257 | rounds = start_response["rounds"] 258 | 259 | # Step 2 finish authentication (RFC5802) 260 | salted_passwd = hashlib.pbkdf2_hmac( 261 | "sha256", self._key.encode("utf-8"), salt, rounds 262 | ) 263 | client_key = hmac.new( 264 | salted_passwd, "Client Key".encode("utf-8"), hashlib.sha256 265 | ).digest() 266 | stored_key = hashlib.sha256(client_key).digest() 267 | 268 | auth_msg = ( 269 | "n={user},r={client_nonce},r={server_nonce},s={salt},i={rounds}," 270 | "c=biws,r={server_nonce}".format( 271 | user=self._user, 272 | client_nonce=b64encode(client_nonce).decode("utf-8"), 273 | server_nonce=b64encode(server_nonce).decode("utf-8"), 274 | salt=b64encode(salt).decode("utf-8"), 275 | rounds=rounds, 276 | ) 277 | ) 278 | client_signature = hmac.new( 279 | stored_key, auth_msg.encode("utf-8"), hashlib.sha256 280 | ).digest() 281 | client_proof = bytes(a ^ b for a, b in zip(client_key, client_signature)) 282 | 283 | server_key = hmac.new( 284 | salted_passwd, "Server Key".encode("utf-8"), hashlib.sha256 285 | ).digest() 286 | server_signature = hmac.new( 287 | server_key, auth_msg.encode("utf-8"), hashlib.sha256 288 | ).digest() 289 | 290 | finish_request = { 291 | "transactionId": b64encode(transaction_id).decode("utf-8"), 292 | "proof": b64encode(client_proof).decode("utf-8"), 293 | } 294 | 295 | async with self.websession.request( 296 | "POST", self._create_url("auth/finish"), json=finish_request 297 | ) as resp: 298 | await self._check_response(resp) 299 | finish_response = await resp.json() 300 | token = finish_response["token"] 301 | signature = b64decode(finish_response["signature"]) 302 | if signature != server_signature: 303 | raise Exception("Server signature mismatch.") 304 | 305 | # Step 3 create session 306 | session_key_hmac = hmac.new( 307 | stored_key, "Session Key".encode("utf-8"), hashlib.sha256 308 | ) 309 | session_key_hmac.update(auth_msg.encode("utf-8")) 310 | session_key_hmac.update(client_key) 311 | protocol_key = session_key_hmac.digest() 312 | session_nonce = urandom(16) 313 | cipher = AES.new(protocol_key, AES.MODE_GCM, nonce=session_nonce) 314 | 315 | if self._user == "master": 316 | token = f"{token}:{self._service_code}" 317 | 318 | cipher_text, auth_tag = cipher.encrypt_and_digest(token.encode("utf-8")) 319 | 320 | session_request = { 321 | # AES initialization vector 322 | "iv": b64encode(session_nonce).decode("utf-8"), 323 | # AES GCM tag 324 | "tag": b64encode(auth_tag).decode("utf-8"), 325 | # ID of authentication transaction 326 | "transactionId": b64encode(transaction_id).decode("utf-8"), 327 | # Only the token or token and service code (separated by colon). Encrypted 328 | # with AES using the protocol key 329 | "payload": b64encode(cipher_text).decode("utf-8"), 330 | } 331 | 332 | async with self.websession.request( 333 | "POST", self._create_url("auth/create_session"), json=session_request 334 | ) as resp: 335 | await self._check_response(resp) 336 | session_response = await resp.json() 337 | self.session_id = session_response["sessionId"] 338 | 339 | def _session_request(self, path: str, method="GET", **kwargs): 340 | """Make an request on the current active session. 341 | 342 | :param path: the URL suffix 343 | :param method: the request method, defaults to 'GET' 344 | :param **kwargs: all other args are forwarded to the request 345 | """ 346 | 347 | headers: Dict[str, str] = {} 348 | if self.session_id is not None: 349 | headers["authorization"] = f"Session {self.session_id}" 350 | 351 | return self.websession.request( 352 | method, self._create_url(path), headers=headers, **kwargs 353 | ) 354 | 355 | async def _check_response(self, resp: ClientResponse): 356 | """Check if the given response contains an error and throws 357 | the appropriate exception.""" 358 | 359 | if resp.status == 200: 360 | return 361 | 362 | try: 363 | response = await resp.json() 364 | error = response["message"] 365 | except Exception: 366 | error = None 367 | 368 | if resp.status == 400: 369 | raise AuthenticationException(resp.status, error) 370 | 371 | if resp.status == 401: 372 | raise NotAuthorizedException(resp.status, error) 373 | 374 | if resp.status == 403: 375 | raise UserLockedException(resp.status, error) 376 | 377 | if resp.status == 404: 378 | raise ModuleNotFoundException(resp.status, error) 379 | 380 | if resp.status == 503: 381 | raise InternalCommunicationException(resp.status, error) 382 | 383 | # we got an undocumented status code 384 | raise ApiException(f"Unknown API response [{resp.status}] - {error}") 385 | 386 | async def logout(self): 387 | """Logs the current user out.""" 388 | self._key = None 389 | self._service_code = None 390 | async with self._session_request("auth/logout", method="POST") as resp: 391 | await self._check_response(resp) 392 | 393 | async def get_me(self) -> MeData: 394 | """Returns information about the user. 395 | 396 | No login is required. 397 | """ 398 | async with self._session_request("auth/me") as resp: 399 | await self._check_response(resp) 400 | me_response = await resp.json() 401 | return MeData(**me_response) 402 | 403 | async def get_version(self) -> VersionData: 404 | """Returns information about the API of the inverter. 405 | 406 | No login is required. 407 | """ 408 | async with self._session_request("info/version") as resp: 409 | await self._check_response(resp) 410 | response = await resp.json() 411 | return VersionData(**response) 412 | 413 | @_relogin 414 | async def get_events(self, max_count=10, lang=None) -> Iterable[EventData]: 415 | """Returns a list with the latest localized events. 416 | 417 | :param max_count: the max number of events to read 418 | :param lang: the RFC1766 based language code, for example 'de_CH' or 'en' 419 | """ 420 | if lang is None: 421 | lang = locale.getlocale()[0] 422 | 423 | language = lang[:2].lower() 424 | variant = lang[3:5].lower() 425 | if language not in ApiClient.SUPPORTED_LANGUAGES.keys(): 426 | # Fallback to default 427 | language = "en" 428 | variant = "gb" 429 | else: 430 | variants = ApiClient.SUPPORTED_LANGUAGES[language] 431 | if variant not in variants: 432 | variant = variants[0] 433 | 434 | request = {"language": f"{language}-{variant}", "max": max_count} 435 | 436 | async with self._session_request( 437 | "events/latest", method="POST", json=request 438 | ) as resp: 439 | await self._check_response(resp) 440 | event_response = await resp.json() 441 | return [EventData(**x) for x in event_response] 442 | 443 | async def get_modules(self) -> Iterable[ModuleData]: 444 | """Return list of all available modules (providing process data or settings).""" 445 | async with self._session_request("modules") as resp: 446 | await self._check_response(resp) 447 | modules_response = await resp.json() 448 | return [ModuleData(**x) for x in modules_response] 449 | 450 | @_relogin 451 | async def get_process_data(self) -> Mapping[str, Iterable[str]]: 452 | """Return a dictionary of all processdata ids and its module ids. 453 | 454 | :return: a dictionary with the module id as key and a list of process data ids 455 | as value 456 | """ 457 | async with self._session_request("processdata") as resp: 458 | await self._check_response(resp) 459 | data_response = await resp.json() 460 | return {x["moduleid"]: x["processdataids"] for x in data_response} 461 | 462 | @overload 463 | async def get_process_data_values( 464 | self, 465 | module_id: str, 466 | processdata_id: str, 467 | ) -> Mapping[str, ProcessDataCollection]: ... 468 | 469 | @overload 470 | async def get_process_data_values( 471 | self, 472 | module_id: str, 473 | processdata_id: Iterable[str], 474 | ) -> Mapping[str, ProcessDataCollection]: ... 475 | 476 | @overload 477 | async def get_process_data_values( 478 | self, 479 | module_id: str, 480 | ) -> Mapping[str, ProcessDataCollection]: ... 481 | 482 | @overload 483 | async def get_process_data_values( 484 | self, 485 | module_id: Mapping[str, Iterable[str]], 486 | ) -> Mapping[str, ProcessDataCollection]: ... 487 | 488 | @overload 489 | async def get_process_data_values( 490 | self, 491 | module_id: Union[str, Mapping[str, Iterable[str]]], 492 | processdata_id: Union[str, Iterable[str], None] = None, 493 | ) -> Mapping[str, ProcessDataCollection]: ... 494 | 495 | @_relogin 496 | async def get_process_data_values( 497 | self, 498 | module_id: Union[str, Mapping[str, Iterable[str]]], 499 | processdata_id: Union[str, Iterable[str], None] = None, 500 | ) -> Mapping[str, ProcessDataCollection]: 501 | """Return a dictionary of process data of one or more modules. 502 | 503 | :param module_id: required, must be a module id or a mapping with the 504 | module id as key and the process data ids as values. 505 | :param processdata_id: optional, if given `module_id` must be string. Can 506 | be either a string or a list of string. If missing 507 | all process data ids are returned. 508 | :return: a dictionary with the module id as key and a instance of 509 | :py:class:`ProcessDataCollection` as value 510 | """ 511 | 512 | if isinstance(module_id, str) and processdata_id is None: 513 | # get all process data of a module 514 | async with self._session_request(f"processdata/{module_id}") as resp: 515 | await self._check_response(resp) 516 | data_response = await resp.json() 517 | return { 518 | data_response[0]["moduleid"]: ProcessDataCollection( 519 | process_data_list(data_response[0]["processdata"]) 520 | ) 521 | } 522 | 523 | if isinstance(module_id, str) and isinstance(processdata_id, str): 524 | # get a single process data of a module 525 | async with self._session_request( 526 | f"processdata/{module_id}/{processdata_id}" 527 | ) as resp: 528 | await self._check_response(resp) 529 | data_response = await resp.json() 530 | return { 531 | data_response[0]["moduleid"]: ProcessDataCollection( 532 | process_data_list(data_response[0]["processdata"]) 533 | ) 534 | } 535 | 536 | if ( 537 | isinstance(module_id, str) 538 | and processdata_id is not None 539 | and hasattr(processdata_id, "__iter__") 540 | ): 541 | # get multiple process data of a module 542 | ids = ",".join(processdata_id) 543 | async with self._session_request(f"processdata/{module_id}/{ids}") as resp: 544 | await self._check_response(resp) 545 | data_response = await resp.json() 546 | return { 547 | data_response[0]["moduleid"]: ProcessDataCollection( 548 | process_data_list(data_response[0]["processdata"]) 549 | ) 550 | } 551 | 552 | if isinstance(module_id, dict) and processdata_id is None: 553 | # get multiple process data of multiple modules 554 | request = [] 555 | for mid, pids in module_id.items(): 556 | # the json encoder expects that iterables are either list or tuples, 557 | # other types has to be converted 558 | if isinstance(pids, (list, tuple)): 559 | request.append(dict(moduleid=mid, processdataids=pids)) 560 | else: 561 | request.append(dict(moduleid=mid, processdataids=list(pids))) 562 | 563 | async with self._session_request( 564 | "processdata", method="POST", json=request 565 | ) as resp: 566 | await self._check_response(resp) 567 | data_response = await resp.json() 568 | return { 569 | x["moduleid"]: ProcessDataCollection( 570 | process_data_list(x["processdata"]) 571 | ) 572 | for x in data_response 573 | } 574 | 575 | raise TypeError("Invalid combination of module_id and processdata_id.") 576 | 577 | async def get_settings(self) -> Mapping[str, Iterable[SettingsData]]: 578 | """Return list of all modules with a list of available settings identifiers.""" 579 | async with self._session_request("settings") as resp: 580 | await self._check_response(resp) 581 | response = await resp.json() 582 | result: Dict[str, List[SettingsData]] = {} 583 | for module in response: 584 | mid = module["moduleid"] 585 | data = [SettingsData(**x) for x in module["settings"]] 586 | result[mid] = data 587 | 588 | return result 589 | 590 | @overload 591 | async def get_setting_values( 592 | self, 593 | module_id: str, 594 | setting_id: str, 595 | ) -> Mapping[str, Mapping[str, str]]: ... 596 | 597 | @overload 598 | async def get_setting_values( 599 | self, 600 | module_id: str, 601 | setting_id: Iterable[str], 602 | ) -> Mapping[str, Mapping[str, str]]: ... 603 | 604 | @overload 605 | async def get_setting_values( 606 | self, 607 | module_id: str, 608 | ) -> Mapping[str, Mapping[str, str]]: ... 609 | 610 | @overload 611 | async def get_setting_values( 612 | self, 613 | module_id: Mapping[str, Iterable[str]], 614 | ) -> Mapping[str, Mapping[str, str]]: ... 615 | 616 | @_relogin 617 | async def get_setting_values( 618 | self, 619 | module_id: Union[str, Mapping[str, Iterable[str]]], 620 | setting_id: Union[str, Iterable[str], None] = None, 621 | ) -> Mapping[str, Mapping[str, str]]: 622 | """Return a dictionary of setting values of one or more modules. 623 | 624 | :param module_id: required, must be a module id or a dictionary with the 625 | module id as key and the setting ids as values. 626 | :param setting_id: optional, if given `module_id` must be string. Can 627 | be either a string or a list of string. If missing 628 | all setting ids are returned. 629 | """ 630 | if isinstance(module_id, str) and setting_id is None: 631 | # get all setting data of a module 632 | async with self._session_request(f"settings/{module_id}") as resp: 633 | await self._check_response(resp) 634 | data_response = await resp.json() 635 | return {module_id: {data_response[0]["id"]: data_response[0]["value"]}} 636 | 637 | if isinstance(module_id, str) and isinstance(setting_id, str): 638 | # get a single setting of a module 639 | async with self._session_request( 640 | f"settings/{module_id}/{setting_id}" 641 | ) as resp: 642 | await self._check_response(resp) 643 | data_response = await resp.json() 644 | return {module_id: {data_response[0]["id"]: data_response[0]["value"]}} 645 | 646 | if ( 647 | isinstance(module_id, str) 648 | and setting_id is not None 649 | and hasattr(setting_id, "__iter__") 650 | ): 651 | # get multiple settings of a module 652 | ids = ",".join(setting_id) 653 | async with self._session_request(f"settings/{module_id}/{ids}") as resp: 654 | await self._check_response(resp) 655 | data_response = await resp.json() 656 | return {module_id: {x["id"]: x["value"] for x in data_response}} 657 | 658 | if isinstance(module_id, dict) and setting_id is None: 659 | # get multiple process data of multiple modules 660 | request = [] 661 | for mid, pids in module_id.items(): 662 | # the json encoder expects that iterables are either list or tuples, 663 | # other types has to be converted 664 | if isinstance(pids, (list, tuple)): 665 | request.append(dict(moduleid=mid, settingids=pids)) 666 | else: 667 | request.append(dict(moduleid=mid, settingids=list(pids))) 668 | 669 | async with self._session_request( 670 | "settings", method="POST", json=request 671 | ) as resp: 672 | await self._check_response(resp) 673 | data_response = await resp.json() 674 | return { 675 | x["moduleid"]: {y["id"]: y["value"] for y in x["settings"]} 676 | for x in data_response 677 | } 678 | 679 | raise TypeError("Invalid combination of module_id and setting_id.") 680 | 681 | @_relogin 682 | async def set_setting_values(self, module_id: str, values: Mapping[str, str]): 683 | """Write a list of settings for one modules.""" 684 | request = [ 685 | { 686 | "moduleid": module_id, 687 | "settings": [dict(value=v, id=k) for k, v in values.items()], 688 | } 689 | ] 690 | async with self._session_request( 691 | "settings", method="PUT", json=request 692 | ) as resp: 693 | await self._check_response(resp) 694 | 695 | @_relogin 696 | async def download_logdata( 697 | self, 698 | writer: IO, 699 | begin: Union[datetime, None] = None, 700 | end: Union[datetime, None] = None, 701 | ): 702 | """Download logdata as tab-separated file.""" 703 | request = {} 704 | if begin is not None: 705 | request["begin"] = begin.strftime("%Y-%m-%d") 706 | if end is not None: 707 | request["end"] = end.strftime("%Y-%m-%d") 708 | 709 | async with self._session_request( 710 | "logdata/download", 711 | method="POST", 712 | json=request, 713 | timeout=ClientTimeout(total=360), 714 | ) as resp: 715 | await self._check_response(resp) 716 | async for data in resp.content.iter_any(): 717 | writer.write(data.decode("UTF-8")) 718 | -------------------------------------------------------------------------------- /pykoplenti/cli.py: -------------------------------------------------------------------------------- 1 | from ast import literal_eval 2 | import asyncio 3 | from collections import defaultdict 4 | from dataclasses import dataclass 5 | from inspect import iscoroutinefunction 6 | import os 7 | from pathlib import Path 8 | from pprint import pprint 9 | import re 10 | import tempfile 11 | import traceback 12 | from typing import Any, Awaitable, Callable, Dict, Optional, Union 13 | import warnings 14 | 15 | from aiohttp import ClientSession, ClientTimeout 16 | import click 17 | from prompt_toolkit import PromptSession, print_formatted_text 18 | 19 | from pykoplenti import ApiClient 20 | from pykoplenti.extended import ExtendedApiClient 21 | 22 | 23 | class SessionCache: 24 | """Persistent the session in a temporary file.""" 25 | 26 | def __init__(self, host: str, user: str): 27 | self._cache_file = Path( 28 | tempfile.gettempdir(), f"pykoplenti-session-{host}-{user}" 29 | ) 30 | 31 | def read_session_id(self) -> Union[str, None]: 32 | if self._cache_file.is_file(): 33 | with self._cache_file.open("rt") as f: 34 | return f.readline(256) 35 | else: 36 | return None 37 | 38 | def write_session_id(self, id: str): 39 | f = os.open(self._cache_file, os.O_WRONLY | os.O_TRUNC | os.O_CREAT, mode=0o600) 40 | try: 41 | os.write(f, id.encode("ascii")) 42 | finally: 43 | os.close(f) 44 | 45 | def remove(self): 46 | self._cache_file.unlink(missing_ok=True) 47 | 48 | 49 | class ApiShell: 50 | """Provides a shell-like access to the inverter.""" 51 | 52 | def __init__(self, client: ApiClient, user: str): 53 | super().__init__() 54 | self.client = client 55 | self._session_cache = SessionCache(self.client.host, user) 56 | 57 | async def prepare_client(self, key: Optional[str], service_code: Optional[str]): 58 | # first try to reuse existing session 59 | session_id = self._session_cache.read_session_id() 60 | if session_id is not None: 61 | self.client.session_id = session_id 62 | print_formatted_text("Trying to reuse existing session... ", end="") 63 | me = await self.client.get_me() 64 | if me.is_authenticated: 65 | print_formatted_text("Success") 66 | return 67 | 68 | print_formatted_text("Failed") 69 | 70 | if key is not None: 71 | print_formatted_text("Logging in... ", end="") 72 | await self.client.login(key=key, service_code=service_code) 73 | if self.client.session_id is not None: 74 | self._session_cache.write_session_id(self.client.session_id) 75 | print_formatted_text("Success") 76 | else: 77 | print_formatted_text("Session could not be reused and no key given") 78 | 79 | def print_exception(self): 80 | """Prints an excpetion from executing a method.""" 81 | print_formatted_text(traceback.format_exc()) 82 | 83 | async def run(self, key: Optional[str], service_code: Optional[str]): 84 | session = PromptSession[str]() 85 | print_formatted_text(flush=True) # Initialize output 86 | 87 | # Test commands: 88 | # get_settings 89 | # get_setting_values 'devices:local' 'Battery:MinSoc' 90 | # get_setting_values 'devices:local' ['Battery:MinSoc', \ 91 | # 'Battery:MinHomeComsumption'] 92 | # get_setting_values 'scb:time' 93 | # set_setting_values 'devices:local' {'Battery:MinSoc':'15'} 94 | 95 | await self.prepare_client(key, service_code) 96 | 97 | while True: 98 | try: 99 | text = await session.prompt_async("(pykoplenti)> ") 100 | 101 | if text.strip().lower() == "exit": 102 | raise EOFError() 103 | 104 | if text.strip() == "": 105 | continue 106 | else: 107 | # TODO split does not know about lists or dicts or strings 108 | # with spaces 109 | method_name, *arg_values = text.strip().split() 110 | 111 | if method_name == "help": 112 | self._do_help(arg_values) 113 | continue 114 | 115 | method = self._get_method(method_name) 116 | if method is None: 117 | continue 118 | 119 | args = self._create_args(arg_values) 120 | if args is None: 121 | continue 122 | 123 | await self._execute(method, args) 124 | 125 | except KeyboardInterrupt: 126 | continue 127 | except EOFError: 128 | break 129 | 130 | def _do_help(self, argv): 131 | if len(argv) == 0: 132 | print_formatted_text("Try: help ") 133 | else: 134 | method = getattr(self.client, argv[0]) 135 | print_formatted_text(method.__doc__) 136 | 137 | def _get_method(self, name): 138 | try: 139 | return getattr(self.client, name) 140 | except AttributeError: 141 | print_formatted_text(f"Unknown method: {name}") 142 | return None 143 | 144 | def _create_args(self, argv): 145 | try: 146 | return [literal_eval(x) for x in argv] 147 | except Exception: 148 | print_formatted_text("Error parsing arguments") 149 | self.print_exception() 150 | return None 151 | 152 | async def _execute(self, method, args): 153 | try: 154 | if iscoroutinefunction(method): 155 | result = await method(*args) 156 | else: 157 | result = method(*args) 158 | 159 | pprint(result) 160 | except Exception: 161 | print_formatted_text("Error executing method") 162 | self.print_exception() 163 | 164 | 165 | async def repl_main( 166 | host: str, port: int, key: Optional[str], service_code: Optional[str] 167 | ): 168 | async with ClientSession(timeout=ClientTimeout(total=10)) as session: 169 | client = ExtendedApiClient(session, host=host, port=port) 170 | 171 | shell = ApiShell(client, "user" if service_code is None else "master") 172 | await shell.run(key, service_code) 173 | 174 | 175 | async def command_main( 176 | host: str, 177 | port: int, 178 | key: Optional[str], 179 | service_code: Optional[str], 180 | fn: Callable[[ApiClient], Awaitable[Any]], 181 | ): 182 | async with ClientSession(timeout=ClientTimeout(total=10)) as session: 183 | client = ExtendedApiClient(session, host=host, port=port) 184 | session_cache = SessionCache(host, "user" if service_code is None else "master") 185 | 186 | # Try to reuse an existing session 187 | client.session_id = session_cache.read_session_id() 188 | me = await client.get_me() 189 | if not me.is_authenticated: 190 | if key is None: 191 | raise ValueError("Could not reuse session and no login key is given.") 192 | 193 | # create a new session 194 | await client.login(key=key, service_code=service_code) 195 | 196 | if client.session_id is not None: 197 | session_cache.write_session_id(client.session_id) 198 | 199 | await fn(client) 200 | 201 | 202 | @dataclass 203 | class GlobalArgs: 204 | """Global arguments over all sub commands.""" 205 | 206 | host: str = "" 207 | """The hostname or ip of the inverter.""" 208 | 209 | port: int = 0 210 | """The port on which the API listens on the inverter.""" 211 | 212 | key: Optional[str] = None 213 | """The key (password or master key) to login into the API. 214 | 215 | If None, a previous session cache is used. If the session 216 | cache has no valid session, no login is executed. 217 | """ 218 | 219 | service_code: Optional[str] = None 220 | """The service code for master access. 221 | 222 | Only necessary for master access. If missing, user acess is used. 223 | """ 224 | 225 | 226 | pass_global_args = click.make_pass_decorator(GlobalArgs, ensure=True) 227 | 228 | 229 | def _parse_credentials_file(path: Path) -> tuple[Optional[str], Optional[str]]: 230 | """Parse credentials file returning (key, service_code)""" 231 | key = service_code = None 232 | for line in path.read_text().splitlines(): 233 | if "=" not in line: 234 | return line.strip(), None 235 | 236 | name, _, value = line.partition("=") 237 | name = name.strip() 238 | if name in ("password", "key", "master-key"): 239 | key = value.strip() 240 | elif name == "service-code": 241 | service_code = value.strip() 242 | return key, service_code 243 | 244 | 245 | @click.group() 246 | @click.option("--host", help="Hostname or IP of the inverter") 247 | @click.option("--port", default=80, help="Port of the inverter", show_default=True) 248 | @click.option( 249 | "--password", default=None, help="Password or master key (also device id)" 250 | ) 251 | @click.option("--service-code", default=None, help="service code for installer access") 252 | @click.option( 253 | "--password-file", 254 | default="secrets", 255 | help="Path to password file - deprecated, use --credentials", 256 | show_default=True, 257 | type=click.Path(exists=False, dir_okay=False, readable=True, path_type=Path), 258 | ) 259 | @click.option( 260 | "--credentials", 261 | default=None, 262 | help="Path to the credentials file. This has a simple ini-format without sections. " 263 | "For user access, use the 'password'. For installer access, use the 'master-key' " 264 | "and 'service-key'.", 265 | type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path), 266 | ) 267 | @pass_global_args 268 | def cli( 269 | global_args: GlobalArgs, 270 | host: str, 271 | port: int, 272 | password: Optional[str], 273 | service_code: Optional[str], 274 | password_file: Path, 275 | credentials: Path, 276 | ): 277 | """Handling of global arguments with click""" 278 | global_args.host = host 279 | global_args.port = port 280 | 281 | if password is not None: 282 | global_args.key = password 283 | elif password_file.is_file(): 284 | with password_file.open("rt") as f: 285 | global_args.key = f.readline() 286 | warnings.warn( 287 | "--password-file is deprecated. Use --credentials instead.", 288 | DeprecationWarning, 289 | ) 290 | 291 | if service_code is not None: 292 | global_args.service_code = service_code 293 | 294 | if credentials is not None: 295 | if password is not None: 296 | raise click.BadOptionUsage( 297 | "password", "password cannot be used with credentials" 298 | ) 299 | if password_file is not None and password_file.is_file(): 300 | raise click.BadOptionUsage( 301 | "password-file", "password-file cannot be used with credentials" 302 | ) 303 | if service_code is not None: 304 | raise click.BadOptionUsage( 305 | "service_code", "service_code cannot be used with credentials" 306 | ) 307 | 308 | global_args.key, global_args.service_code = _parse_credentials_file(credentials) 309 | 310 | 311 | @cli.command() 312 | @pass_global_args 313 | def repl(global_args: GlobalArgs): 314 | """Provides a simple REPL for executing API requests to the inverter.""" 315 | asyncio.run( 316 | repl_main( 317 | global_args.host, 318 | global_args.port, 319 | global_args.key, 320 | global_args.service_code, 321 | ) 322 | ) 323 | 324 | 325 | @cli.command() 326 | @click.option("--lang", default=None, help="language for events") 327 | @click.option("--count", default=10, help="number of events to read") 328 | @pass_global_args 329 | def read_events(global_args: GlobalArgs, lang, count): 330 | """Returns the last events""" 331 | 332 | async def fn(client: ApiClient): 333 | data = await client.get_events(lang=lang, max_count=count) 334 | for event in data: 335 | print( 336 | f"{event.is_active < 5} {event.start_time} {event.end_time} " 337 | f"{event.description}" 338 | ) 339 | 340 | asyncio.run( 341 | command_main( 342 | global_args.host, 343 | global_args.port, 344 | global_args.key, 345 | global_args.service_code, 346 | fn, 347 | ) 348 | ) 349 | 350 | 351 | @cli.command() 352 | @click.option( 353 | "--out", 354 | required=True, 355 | type=click.File(mode="wt", encoding="UTF-8"), 356 | help="file to write the log data to", 357 | ) 358 | @click.option("--begin", type=click.DateTime(["%Y-%m-%d"]), help="first day to export") 359 | @click.option("--end", type=click.DateTime(["%Y-%m-%d"]), help="last day to export") 360 | @pass_global_args 361 | def download_log(global_args: GlobalArgs, out, begin, end): 362 | """Download the log data from the inverter to a file.""" 363 | 364 | async def fn(client: ApiClient): 365 | await client.download_logdata(writer=out, begin=begin, end=end) 366 | 367 | asyncio.run( 368 | command_main( 369 | global_args.host, 370 | global_args.port, 371 | global_args.key, 372 | global_args.service_code, 373 | fn, 374 | ) 375 | ) 376 | 377 | 378 | @cli.command() 379 | @pass_global_args 380 | def all_processdata(global_args: GlobalArgs): 381 | """Returns a list of all available process data.""" 382 | 383 | async def fn(client: ApiClient): 384 | data = await client.get_process_data() 385 | for k, v in data.items(): 386 | for x in v: 387 | print(f"{k}/{x}") 388 | 389 | asyncio.run( 390 | command_main( 391 | global_args.host, 392 | global_args.port, 393 | global_args.key, 394 | global_args.service_code, 395 | fn, 396 | ) 397 | ) 398 | 399 | 400 | @cli.command() 401 | @click.argument("ids", required=True, nargs=-1) 402 | @pass_global_args 403 | def read_processdata(global_args: GlobalArgs, ids): 404 | """Returns the values of the given process data. 405 | 406 | IDS is the identifier (/) of one or more processdata 407 | to read. 408 | 409 | \b 410 | Examples: 411 | read-processdata devices:local/Inverter:State 412 | """ 413 | 414 | async def fn(client: ApiClient): 415 | if len(ids) == 1 and "/" not in ids[0]: 416 | # all process data ids of a moudle 417 | values = await client.get_process_data_values(ids[0]) 418 | else: 419 | query = defaultdict(list) 420 | for id in ids: 421 | m = re.match(r"(?P.+)/(?P.+)", id) 422 | if not m: 423 | raise Exception(f"Invalid format of {id}") 424 | 425 | module_id = m.group("module_id") 426 | setting_id = m.group("processdata_id") 427 | 428 | query[module_id].append(setting_id) 429 | 430 | values = await client.get_process_data_values(query) 431 | 432 | for k, v in values.items(): 433 | for x in v.values(): 434 | print(f"{k}/{x.id}={x.value}") 435 | 436 | asyncio.run( 437 | command_main( 438 | global_args.host, 439 | global_args.port, 440 | global_args.key, 441 | global_args.service_code, 442 | fn, 443 | ) 444 | ) 445 | 446 | 447 | @cli.command() 448 | @click.option( 449 | "--rw", is_flag=True, default=False, help="display only writable settings" 450 | ) 451 | @pass_global_args 452 | def all_settings(global_args: GlobalArgs, rw: bool): 453 | """Returns the ids of all settings.""" 454 | 455 | async def fn(client: ApiClient): 456 | settings = await client.get_settings() 457 | for k, v in settings.items(): 458 | for x in v: 459 | if not rw or x.access == "readwrite": 460 | print(f"{k}/{x.id}") 461 | 462 | asyncio.run( 463 | command_main( 464 | global_args.host, 465 | global_args.port, 466 | global_args.key, 467 | global_args.service_code, 468 | fn, 469 | ) 470 | ) 471 | 472 | 473 | @cli.command() 474 | @click.argument("ids", required=True, nargs=-1) 475 | @pass_global_args 476 | def read_settings(global_args: GlobalArgs, ids): 477 | """Read the value of the given settings. 478 | 479 | IDS is the identifier (/) of one or more settings to read 480 | 481 | \b 482 | Examples: 483 | read-settings devices:local/Battery:MinSoc 484 | read-settings devices:local/Battery:MinSoc \ 485 | devices:local/Battery:MinHomeComsumption 486 | """ 487 | 488 | async def fn(client: ApiClient): 489 | query = defaultdict(list) 490 | for id in ids: 491 | m = re.match(r"(?P.+)/(?P.+)", id) 492 | if not m: 493 | raise Exception(f"Invalid format of {id}") 494 | 495 | module_id = m.group("module_id") 496 | setting_id = m.group("setting_id") 497 | 498 | query[module_id].append(setting_id) 499 | 500 | values = await client.get_setting_values(query) 501 | 502 | for k, x in values.items(): 503 | for i, v in x.items(): 504 | print(f"{k}/{i}={v}") 505 | 506 | asyncio.run( 507 | command_main( 508 | global_args.host, 509 | global_args.port, 510 | global_args.key, 511 | global_args.service_code, 512 | fn, 513 | ) 514 | ) 515 | 516 | 517 | @cli.command() 518 | @click.argument("id_values", required=True, nargs=-1) 519 | @pass_global_args 520 | def write_settings(global_args: GlobalArgs, id_values): 521 | """Write the values of the given settings. 522 | 523 | ID_VALUES is the identifier plus the the value to write 524 | 525 | \b 526 | Examples: 527 | write-settings devices:local/Battery:MinSoc=15 528 | """ 529 | 530 | async def fn(client: ApiClient): 531 | query: Dict[str, Dict[str, str]] = defaultdict(dict) 532 | for id_value in id_values: 533 | m = re.match( 534 | r"(?P.+)/(?P.+)=(?P.+)", id_value 535 | ) 536 | if not m: 537 | raise Exception(f"Invalid format of {id_value}") 538 | 539 | module_id = m.group("module_id") 540 | setting_id = m.group("setting_id") 541 | value = m.group("value") 542 | 543 | query[module_id][setting_id] = value 544 | 545 | for module_id, setting_values in query.items(): 546 | await client.set_setting_values(module_id, setting_values) 547 | 548 | asyncio.run( 549 | command_main( 550 | global_args.host, 551 | global_args.port, 552 | global_args.key, 553 | global_args.service_code, 554 | fn, 555 | ) 556 | ) 557 | 558 | 559 | # entry point for pycharm; should not be used for commandline usage 560 | if __name__ == "__main__": 561 | import sys 562 | 563 | cli(sys.argv[1:], auto_envvar_prefix="PYKOPLENTI") 564 | -------------------------------------------------------------------------------- /pykoplenti/extended.py: -------------------------------------------------------------------------------- 1 | """Extended ApiClient which provides virtual process data values.""" 2 | 3 | from abc import ABC, abstractmethod 4 | from collections import ChainMap, defaultdict 5 | from typing import Final, Iterable, Literal, Mapping, MutableMapping, Union 6 | 7 | from aiohttp import ClientSession 8 | 9 | from .api import ApiClient 10 | from .model import ProcessData, ProcessDataCollection 11 | 12 | _VIRT_MODUL_ID: Final = "_virt_" 13 | 14 | 15 | class _VirtProcessDataItemBase(ABC): 16 | """Base class for all virtual process data items.""" 17 | 18 | def __init__(self, processid: str, process_data: dict[str, set[str]]) -> None: 19 | self.processid = processid 20 | self.process_data = process_data 21 | self.available_process_data: dict[str, set[str]] = {} 22 | 23 | def update_actual_process_ids( 24 | self, available_process_ids: Mapping[str, Iterable[str]] 25 | ): 26 | """Update which process data for this item are available.""" 27 | self.available_process_data.clear() 28 | for module_id, process_ids in self.process_data.items(): 29 | if module_id in available_process_ids: 30 | matching_process_ids = process_ids.intersection( 31 | available_process_ids[module_id] 32 | ) 33 | if len(matching_process_ids) > 0: 34 | self.available_process_data[module_id] = matching_process_ids 35 | 36 | @abstractmethod 37 | def get_value( 38 | self, process_values: Mapping[str, ProcessDataCollection] 39 | ) -> ProcessData: 40 | ... 41 | 42 | @abstractmethod 43 | def is_available(self) -> bool: 44 | ... 45 | 46 | 47 | class _VirtProcessDataItemSum(_VirtProcessDataItemBase): 48 | def get_value( 49 | self, process_values: Mapping[str, ProcessDataCollection] 50 | ) -> ProcessData: 51 | values: list[float] = [] 52 | for module_id, process_ids in self.available_process_data.items(): 53 | values += (process_values[module_id][pid].value for pid in process_ids) 54 | 55 | return ProcessData(id=self.processid, unit="W", value=sum(values)) 56 | 57 | def is_available(self) -> bool: 58 | return len(self.available_process_data) > 0 59 | 60 | 61 | class _VirtProcessDataItemEnergyToGrid(_VirtProcessDataItemBase): 62 | def __init__( 63 | self, processid: str, scope: Literal["Total", "Year", "Month", "Day"] 64 | ) -> None: 65 | super().__init__( 66 | processid, 67 | { 68 | "scb:statistic:EnergyFlow": { 69 | f"Statistic:Yield:{scope}", 70 | f"Statistic:EnergyHomeBat:{scope}", 71 | f"Statistic:EnergyHomePv:{scope}", 72 | } 73 | }, 74 | ) 75 | self.scope = scope 76 | 77 | def get_value( 78 | self, process_values: Mapping[str, ProcessDataCollection] 79 | ) -> ProcessData: 80 | statistics = process_values["scb:statistic:EnergyFlow"] 81 | energy_yield = statistics[f"Statistic:Yield:{self.scope}"].value 82 | energy_home_bat = statistics[f"Statistic:EnergyHomeBat:{self.scope}"].value 83 | energy_home_pv = statistics[f"Statistic:EnergyHomePv:{self.scope}"].value 84 | 85 | return ProcessData( 86 | id=self.processid, 87 | unit="Wh", 88 | value=energy_yield - energy_home_pv - energy_home_bat, 89 | ) 90 | 91 | def is_available(self) -> bool: 92 | return self.available_process_data == self.process_data 93 | 94 | 95 | class _VirtProcessDataManager: 96 | """Manager for all virtual process data items.""" 97 | 98 | def __init__(self) -> None: 99 | self._virt_process_data: Iterable[_VirtProcessDataItemBase] = [ 100 | _VirtProcessDataItemSum( 101 | "pv_P", 102 | { 103 | "devices:local:pv1": {"P"}, 104 | "devices:local:pv2": {"P"}, 105 | "devices:local:pv3": {"P"}, 106 | }, 107 | ), 108 | _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Total", "Total"), 109 | _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Year", "Year"), 110 | _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Month", "Month"), 111 | _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Day", "Day"), 112 | ] 113 | 114 | def initialize(self, available_process_data: Mapping[str, Iterable[str]]): 115 | """Initialize the virtual items with the list of available process ids.""" 116 | for vpd in self._virt_process_data: 117 | vpd.update_actual_process_ids(available_process_data) 118 | 119 | def adapt_process_data_response( 120 | self, process_data: dict[str, list[str]] 121 | ) -> Mapping[str, list[str]]: 122 | """Adapt the reponse of reading process data.""" 123 | virt_process_data: dict[str, list[str]] = {_VIRT_MODUL_ID: []} 124 | 125 | for vpd in self._virt_process_data: 126 | if vpd.is_available(): 127 | virt_process_data[_VIRT_MODUL_ID].append(vpd.processid) 128 | 129 | return ChainMap(process_data, virt_process_data) 130 | 131 | def adapt_process_value_request( 132 | self, process_data: Mapping[str, Iterable[str]] 133 | ) -> Mapping[str, Iterable[str]]: 134 | """Adapt the request for process values.""" 135 | result: MutableMapping[str, set[str]] = defaultdict(set) 136 | 137 | for mid, pids in process_data.items(): 138 | result[mid].update(pids) 139 | 140 | for requested_virtual_process_id in result.pop(_VIRT_MODUL_ID): 141 | for virtual_process_data in self._virt_process_data: 142 | if virtual_process_data.is_available(): 143 | if requested_virtual_process_id == virtual_process_data.processid: 144 | # add ids for virtual if they are missing 145 | for ( 146 | mid, 147 | pids, 148 | ) in virtual_process_data.available_process_data.items(): 149 | result[mid].update(pids) 150 | break 151 | else: 152 | raise ValueError( 153 | f"No virtual process data '{requested_virtual_process_id}'." 154 | ) 155 | 156 | return result 157 | 158 | def adapt_process_value_response( 159 | self, 160 | values: Mapping[str, ProcessDataCollection], 161 | request_data: Mapping[str, Iterable[str]], 162 | ) -> Mapping[str, ProcessDataCollection]: 163 | """Adapt the reponse for process values.""" 164 | result = {} 165 | 166 | # add virtual items 167 | virtual_process_data_values = [] 168 | for id in request_data[_VIRT_MODUL_ID]: 169 | for vpd in self._virt_process_data: 170 | if vpd.processid == id: 171 | virtual_process_data_values.append(vpd.get_value(values)) 172 | result["_virt_"] = ProcessDataCollection(virtual_process_data_values) 173 | 174 | # add all values which was requested but not the extra ids for the virtual ids 175 | for mid, pdc in values.items(): 176 | if mid in request_data: 177 | pids = [x for x in pdc.values() if x.id in request_data[mid]] 178 | if len(pids) > 0: 179 | result[mid] = ProcessDataCollection(pids) 180 | 181 | return result 182 | 183 | 184 | class ExtendedApiClient(ApiClient): 185 | """Extend ApiClient with virtual process data.""" 186 | 187 | def __init__(self, websession: ClientSession, host: str, port: int = 80): 188 | super().__init__(websession, host, port) 189 | 190 | self._virt_process_data = _VirtProcessDataManager() 191 | self._virt_process_data_initialized = False 192 | 193 | async def get_process_data(self) -> Mapping[str, Iterable[str]]: 194 | process_data = await super().get_process_data() 195 | 196 | self._virt_process_data.initialize(process_data) 197 | self._virt_process_data_initialized = True 198 | return self._virt_process_data.adapt_process_data_response(process_data) 199 | 200 | async def get_process_data_values( 201 | self, 202 | module_id: Union[str, Mapping[str, Iterable[str]]], 203 | processdata_id: Union[str, Iterable[str], None] = None, 204 | ) -> Mapping[str, ProcessDataCollection]: 205 | contains_virt_process_data = ( 206 | isinstance(module_id, str) and _VIRT_MODUL_ID == module_id 207 | ) or (isinstance(module_id, dict) and _VIRT_MODUL_ID in module_id) 208 | 209 | if not contains_virt_process_data: 210 | # short-cut if no virtual process is requested 211 | return await super().get_process_data_values(module_id, processdata_id) 212 | 213 | process_data: dict[str, Iterable[str]] = {} 214 | if isinstance(module_id, str) and processdata_id is None: 215 | process_data[module_id] = [] 216 | elif isinstance(module_id, str) and isinstance(processdata_id, str): 217 | process_data[module_id] = [processdata_id] 218 | elif ( 219 | isinstance(module_id, str) 220 | and processdata_id is not None 221 | and hasattr(processdata_id, "__iter__") 222 | ): 223 | process_data[module_id] = list(processdata_id) 224 | elif isinstance(module_id, Mapping) and processdata_id is None: 225 | process_data.update(module_id) 226 | else: 227 | raise TypeError("Invalid combination of module_id and processdata_id.") 228 | 229 | if not self._virt_process_data_initialized: 230 | pd = await self.get_process_data() 231 | self._virt_process_data.initialize(pd) 232 | self._virt_process_data_initialized = True 233 | 234 | process_values = await super().get_process_data_values( 235 | self._virt_process_data.adapt_process_value_request(process_data) 236 | ) 237 | return self._virt_process_data.adapt_process_value_response( 238 | process_values, process_data 239 | ) 240 | -------------------------------------------------------------------------------- /pykoplenti/model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Final, Iterator, Mapping, Optional 3 | 4 | import pydantic 5 | from pydantic import BaseModel, Field 6 | 7 | 8 | class MeData(BaseModel): 9 | """Represent the data of the 'me'-request.""" 10 | 11 | is_locked: bool = Field(alias="locked") 12 | is_active: bool = Field(alias="active") 13 | is_authenticated: bool = Field(alias="authenticated") 14 | permissions: list[str] = Field() 15 | is_anonymous: bool = Field(alias="anonymous") 16 | role: str 17 | 18 | 19 | class VersionData(BaseModel): 20 | """Represent the data of the 'version'-request.""" 21 | 22 | api_version: str 23 | hostname: str 24 | name: str 25 | sw_version: str 26 | 27 | 28 | class ModuleData(BaseModel): 29 | """Represents a single module.""" 30 | 31 | id: str 32 | type: str 33 | 34 | 35 | class ProcessData(BaseModel): 36 | """Represents a single process data.""" 37 | 38 | id: str 39 | unit: str 40 | value: float 41 | 42 | 43 | class ProcessDataCollection(Mapping): 44 | """Represents a collection of process data value.""" 45 | 46 | def __init__(self, process_data: list[ProcessData]): 47 | self._process_data = process_data 48 | 49 | def __len__(self) -> int: 50 | return len(self._process_data) 51 | 52 | def __iter__(self) -> Iterator[str]: 53 | return (x.id for x in self._process_data) 54 | 55 | def __getitem__(self, item) -> ProcessData: 56 | try: 57 | return next(x for x in self._process_data if x.id == item) 58 | except StopIteration: 59 | raise KeyError(item) from None 60 | 61 | def __eq__(self, __other: object) -> bool: 62 | if not isinstance(__other, ProcessDataCollection): 63 | return False 64 | 65 | return self._process_data == __other._process_data 66 | 67 | def __str__(self): 68 | return "[" + ",".join(str(x) for x in self._process_data) + "]" 69 | 70 | def __repr__(self): 71 | return ( 72 | "ProcessDataCollection([" 73 | + ",".join(repr(x) for x in self._process_data) 74 | + "])" 75 | ) 76 | 77 | 78 | class SettingsData(BaseModel): 79 | """Represents a single settings data.""" 80 | 81 | min: Optional[str] 82 | max: Optional[str] 83 | default: Optional[str] 84 | access: str 85 | unit: Optional[str] 86 | id: str 87 | type: str 88 | 89 | 90 | class EventData(BaseModel): 91 | """Represents an event of the inverter.""" 92 | 93 | start_time: datetime 94 | end_time: datetime 95 | code: int 96 | long_description: str 97 | category: str 98 | description: str 99 | group: str 100 | is_active: bool 101 | 102 | 103 | # pydantic version specific code 104 | # In pydantic 2.x `parse_obj_as` is no longer supported. To stay compatible to 105 | # both version a small wrapper function is used. 106 | 107 | if pydantic.VERSION.startswith("2."): 108 | from pydantic import TypeAdapter 109 | 110 | _process_list_adapter: Final = TypeAdapter(list[ProcessData]) 111 | 112 | def process_data_list(json) -> list[ProcessData]: 113 | """Process json as a list of ProcessData objects.""" 114 | return _process_list_adapter.validate_python(json) 115 | 116 | else: 117 | from pydantic import parse_obj_as 118 | 119 | def process_data_list(json) -> list[ProcessData]: 120 | """Process json as a list of ProcessData objects.""" 121 | return parse_obj_as(list[ProcessData], json) 122 | -------------------------------------------------------------------------------- /pykoplenti/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stegm/pykoplenti/6537ebae1bb94bbeab8f44cec9f73724f9df6345/pykoplenti/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | target-version = ["py310"] 7 | 8 | [tool.isort] 9 | profile = "black" 10 | force_sort_within_sections = true 11 | known_first_party = [ 12 | "kostal", 13 | "tests", 14 | ] 15 | forced_separate = [ 16 | "tests", 17 | ] 18 | combine_as_imports = true 19 | 20 | [tool.ruff] 21 | line-length = 88 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pykoplenti 3 | version = 1.4.0 4 | description = Python REST-Client for Kostal Plenticore Solar Inverters 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | keywords = rest kostal plenticore solar 8 | author = @stegm 9 | url = https://github.com/stegm/pyclient_koplenti 10 | project_urls = 11 | repository = https://github.com/stegm/pyclient_koplenti 12 | changelog = https://github.com/stegm/pykoplenti/blob/master/CHANGELOG.md 13 | issues = https://github.com/stegm/pykoplenti/issues 14 | classifiers = 15 | Development Status :: 4 - Beta 16 | Environment :: Console 17 | Intended Audience :: Developers 18 | License :: OSI Approved :: Apache Software License 19 | Programming Language :: Python :: 3 20 | Programming Language :: Python :: 3.9 21 | Programming Language :: Python :: 3.10 22 | Programming Language :: Python :: 3.11 23 | Programming Language :: Python :: 3.12 24 | Topic :: Software Development :: Libraries 25 | 26 | [options] 27 | packages = pykoplenti 28 | install_requires = 29 | aiohttp ~= 3.8 30 | pycryptodome ~= 3.19 31 | pydantic >= 1.10 32 | 33 | [options.package_data] 34 | pykoplenti = py.typed 35 | 36 | [options.extras_require] 37 | CLI = 38 | prompt_toolkit >= 3.0 39 | click >= 8.0 40 | 41 | [options.entry_points] 42 | console_scripts = 43 | pykoplenti = pykoplenti.cli:cli [CLI] 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, Callable, Union 3 | from unittest.mock import AsyncMock, MagicMock 4 | 5 | from aiohttp import ClientResponse, ClientSession 6 | import pytest 7 | 8 | import pykoplenti 9 | 10 | only_smoketest: pytest.MarkDecorator = pytest.mark.skipif( 11 | os.getenv("SMOKETEST_HOST") is None, reason="Smoketest must be explicitly executed" 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def websession_responses() -> list[MagicMock]: 17 | """Provides a mutable list for responses of a ClientSession.""" 18 | return [] 19 | 20 | 21 | @pytest.fixture 22 | def websession(websession_responses) -> MagicMock: 23 | """Creates a mocked ClientSession. 24 | 25 | The client_response_factoryfixture can be used to add responses. 26 | """ 27 | websession = MagicMock(spec_set=ClientSession, name="websession Mock") 28 | websession.request.return_value.__aenter__.side_effect = websession_responses 29 | return websession 30 | 31 | 32 | @pytest.fixture 33 | def client_response_factory( 34 | websession_responses, 35 | ) -> Callable[[int, Any], MagicMock]: 36 | """Provides a factory to add responses to a ClientSession.""" 37 | 38 | def factory(status: int = 200, json: Union[list[Any], dict[Any, Any], None] = None): 39 | response = MagicMock(spec_set=ClientResponse, name="request Mock") 40 | response.status = status 41 | if json is not None: 42 | response.json.return_value = json 43 | 44 | websession_responses.append(response) 45 | return response 46 | 47 | return factory 48 | 49 | 50 | @pytest.fixture 51 | def pykoplenti_client(websession) -> pykoplenti.ApiClient: 52 | """Returns a pykoplenti API-Client. 53 | 54 | The _login method is replaced with an AsyncMock. 55 | """ 56 | client = pykoplenti.ApiClient(websession, "localhost") 57 | login_mock = AsyncMock() 58 | client._login = login_mock # type: ignore 59 | 60 | return client 61 | 62 | 63 | @pytest.fixture 64 | def pykoplenti_extended_client(websession) -> pykoplenti.ExtendedApiClient: 65 | """Returns a pykoplenti Extended API-Client. 66 | 67 | The _login method is replaced with an AsyncMock. 68 | """ 69 | client = pykoplenti.ExtendedApiClient(websession, "localhost") 70 | login_mock = AsyncMock() 71 | client._login = login_mock # type: ignore 72 | 73 | return client 74 | 75 | 76 | @pytest.fixture 77 | def smoketest_config() -> tuple[str, int, str]: 78 | """Return the configuration for smoke tests.""" 79 | return ( 80 | os.getenv("SMOKETEST_HOST", "localhost"), 81 | int(os.getenv("SMOKETEST_PORT", 80)), 82 | os.getenv("SMOKETEST_PASS", ""), 83 | ) 84 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from click.testing import CliRunner 3 | import pytest 4 | from pykoplenti.cli import cli, SessionCache 5 | import os 6 | from conftest import only_smoketest 7 | 8 | 9 | @pytest.fixture 10 | def credentials(tmp_path: Path, smoketest_config: tuple[str, int, str]): 11 | _, _, password = smoketest_config 12 | credentials_path = tmp_path / "credentials" 13 | credentials_path.write_text(f"password={password}") 14 | return credentials_path 15 | 16 | 17 | @pytest.fixture 18 | def dummy_credentials(tmp_path: Path): 19 | credentials_path = tmp_path / "credentials" 20 | credentials_path.write_text("password=dummy") 21 | return credentials_path 22 | 23 | 24 | @pytest.fixture 25 | def session_cache(smoketest_config: tuple[str, int, str]): 26 | host, _, _ = smoketest_config 27 | session_cache = SessionCache(host, "user") 28 | session_cache.remove() 29 | yield session_cache 30 | session_cache.remove() 31 | 32 | 33 | class TestInvalidGlobalOptions: 34 | """Test invalid global options.""" 35 | 36 | def test_crendentials_and_password(self, dummy_credentials: Path): 37 | runner = CliRunner() 38 | result = runner.invoke( 39 | cli, 40 | [ 41 | "--credentials", 42 | str(dummy_credentials), 43 | "--password", 44 | "topsecret", 45 | "all-processdata", 46 | ], 47 | ) 48 | assert result.exit_code == 2 49 | assert "password cannot be used with credentials" in result.output 50 | 51 | @pytest.mark.filterwarnings( 52 | "ignore:--password-file is deprecated. Use --credentials instead." 53 | ) 54 | def test_crendentials_and_password_file(self, dummy_credentials: Path): 55 | runner = CliRunner() 56 | result = runner.invoke( 57 | cli, 58 | [ 59 | "--credentials", 60 | str(dummy_credentials), 61 | "--password-file", 62 | str(dummy_credentials), 63 | "all-processdata", 64 | ], 65 | ) 66 | 67 | assert result.exit_code == 2 68 | assert "password-file cannot be used with credentials" in result.output 69 | 70 | def test_crendentials_and_service_code( 71 | self, dummy_credentials: Path, tmp_path: Path 72 | ): 73 | # As --password-file has a default value, this ensures 74 | # that no default password-file exists. 75 | os.chdir(tmp_path) 76 | 77 | runner = CliRunner() 78 | result = runner.invoke( 79 | cli, 80 | [ 81 | "--credentials", 82 | str(dummy_credentials), 83 | "--service-code", 84 | "topsecret", 85 | "all-processdata", 86 | ], 87 | ) 88 | assert result.exit_code == 2 89 | assert "service_code cannot be used with credentials" in result.output 90 | 91 | 92 | @only_smoketest 93 | def test_read_process_data( 94 | credentials: Path, 95 | session_cache: SessionCache, 96 | smoketest_config: tuple[str, int, str], 97 | ): 98 | # As --password-file has a default value, this ensures 99 | # that no default password-file exists. 100 | os.chdir(credentials.parent) 101 | 102 | host, port, _ = smoketest_config 103 | 104 | runner = CliRunner() 105 | result = runner.invoke( 106 | cli, 107 | [ 108 | "--host", 109 | host, 110 | "--port", 111 | str(port), 112 | "--credentials", 113 | str(credentials), 114 | "all-processdata", 115 | ], 116 | ) 117 | assert result.exit_code == 0 118 | # check any data which is most likely present on most inverter 119 | assert "devices:local/Inverter:State" in result.stdout.splitlines() 120 | assert session_cache.read_session_id() is not None 121 | 122 | 123 | @only_smoketest 124 | def test_read_settings_data( 125 | credentials: Path, 126 | session_cache: SessionCache, 127 | smoketest_config: tuple[str, int, str], 128 | ): 129 | # As --password-file has a default value, this ensures 130 | # that no default password-file exists. 131 | os.chdir(credentials.parent) 132 | 133 | host, port, _ = smoketest_config 134 | 135 | runner = CliRunner() 136 | result = runner.invoke( 137 | cli, 138 | [ 139 | "--host", 140 | host, 141 | "--port", 142 | str(port), 143 | "--credentials", 144 | str(credentials), 145 | "all-settings", 146 | ], 147 | ) 148 | assert result.exit_code == 0 149 | # check any data which is most likely present on most inverter 150 | assert "devices:local/Branding:ProductName1" in result.stdout.splitlines() 151 | assert session_cache.read_session_id() is not None 152 | -------------------------------------------------------------------------------- /tests/test_extendedapiclient.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Iterable, Union 2 | from unittest.mock import ANY, MagicMock, call 3 | 4 | import pytest 5 | 6 | import pykoplenti 7 | 8 | 9 | class _IterableMatcher: 10 | """Matcher for iterable which does not check the order.""" 11 | 12 | def __init__(self, expected: Iterable): 13 | self._expected = list(expected) 14 | 15 | def __eq__(self, other: object) -> bool: 16 | if not hasattr(other, "__iter__"): 17 | return False 18 | 19 | # check if every item in expected matched an item in other 20 | expected = self._expected.copy() 21 | for item in other: 22 | if (idx := expected.index(item)) >= 0: 23 | del expected[idx] 24 | else: 25 | return False 26 | 27 | return len(expected) == 0 28 | 29 | def __str__(self) -> str: 30 | return str(self._expected) 31 | 32 | def __repr__(self) -> str: 33 | return repr(self._expected) 34 | 35 | 36 | class TestVirtualProcessDataValuesDcSum: 37 | """This class contains tests for virtual process data values for DC sum.""" 38 | 39 | @pytest.mark.asyncio 40 | async def test_virtual_process_data( 41 | self, 42 | pykoplenti_extended_client: pykoplenti.ExtendedApiClient, 43 | client_response_factory: Callable[ 44 | [int, Union[list[Any], dict[Any, Any]]], MagicMock 45 | ], 46 | websession: MagicMock, 47 | ): 48 | """Test virtual process data for PV power if depencies are present.""" 49 | client_response_factory( 50 | 200, 51 | [ 52 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 53 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 54 | ], 55 | ) 56 | 57 | values = await pykoplenti_extended_client.get_process_data() 58 | 59 | websession.request.assert_called_once_with( 60 | "GET", 61 | ANY, 62 | headers=ANY, 63 | ) 64 | 65 | assert values == { 66 | "_virt_": ["pv_P"], 67 | "devices:local:pv1": ["P"], 68 | "devices:local:pv2": ["P"], 69 | } 70 | 71 | @pytest.mark.asyncio 72 | async def test_virtual_process_data_value( 73 | self, 74 | pykoplenti_extended_client: pykoplenti.ExtendedApiClient, 75 | client_response_factory: Callable[ 76 | [int, Union[list[Any], dict[Any, Any]]], MagicMock 77 | ], 78 | websession: MagicMock, 79 | ): 80 | """Test virtual process data for PV power.""" 81 | client_response_factory( 82 | 200, 83 | [ 84 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 85 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 86 | {"moduleid": "devices:local:pv3", "processdataids": ["P"]}, 87 | ], 88 | ) 89 | client_response_factory( 90 | 200, 91 | [ 92 | { 93 | "moduleid": "devices:local:pv1", 94 | "processdata": [ 95 | {"id": "P", "unit": "W", "value": 700.0}, 96 | ], 97 | }, 98 | { 99 | "moduleid": "devices:local:pv2", 100 | "processdata": [ 101 | {"id": "P", "unit": "W", "value": 300.0}, 102 | ], 103 | }, 104 | { 105 | "moduleid": "devices:local:pv3", 106 | "processdata": [ 107 | {"id": "P", "unit": "W", "value": 500.0}, 108 | ], 109 | }, 110 | ], 111 | ) 112 | 113 | values = await pykoplenti_extended_client.get_process_data_values( 114 | "_virt_", "pv_P" 115 | ) 116 | 117 | websession.request.assert_has_calls( 118 | [ 119 | call("GET", ANY, headers=ANY), 120 | call( 121 | "POST", 122 | ANY, 123 | headers=ANY, 124 | json=[ 125 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 126 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 127 | {"moduleid": "devices:local:pv3", "processdataids": ["P"]}, 128 | ], 129 | ), 130 | ], 131 | any_order=True, 132 | ) 133 | 134 | assert values == { 135 | "_virt_": pykoplenti.ProcessDataCollection( 136 | [pykoplenti.ProcessData(id="pv_P", unit="W", value=1500.0)] 137 | ) 138 | } 139 | 140 | 141 | class TestVirtualProcessDataValuesEnergyToGrid: 142 | """This class contains tests for virtual process data values for energy to grid.""" 143 | 144 | @pytest.mark.parametrize("scope", ["Total", "Year", "Month", "Day"]) 145 | @pytest.mark.asyncio 146 | async def test_virtual_process_data( 147 | self, 148 | pykoplenti_extended_client: pykoplenti.ExtendedApiClient, 149 | client_response_factory: Callable[ 150 | [int, Union[list[Any], dict[Any, Any]]], MagicMock 151 | ], 152 | websession: MagicMock, 153 | scope: str, 154 | ): 155 | """Test virtual process data.""" 156 | client_response_factory( 157 | 200, 158 | [ 159 | { 160 | "moduleid": "scb:statistic:EnergyFlow", 161 | "processdataids": [ 162 | f"Statistic:Yield:{scope}", 163 | f"Statistic:EnergyHomeBat:{scope}", 164 | f"Statistic:EnergyHomePv:{scope}", 165 | ], 166 | }, 167 | ], 168 | ) 169 | 170 | values = await pykoplenti_extended_client.get_process_data() 171 | 172 | websession.request.assert_called_once_with( 173 | "GET", 174 | ANY, 175 | headers=ANY, 176 | ) 177 | 178 | assert values == { 179 | "_virt_": [f"Statistic:EnergyGrid:{scope}"], 180 | "scb:statistic:EnergyFlow": [ 181 | f"Statistic:Yield:{scope}", 182 | f"Statistic:EnergyHomeBat:{scope}", 183 | f"Statistic:EnergyHomePv:{scope}", 184 | ], 185 | } 186 | 187 | @pytest.mark.parametrize("scope", ["Total", "Year", "Month", "Day"]) 188 | @pytest.mark.asyncio 189 | async def test_virtual_process_data_value( 190 | self, 191 | pykoplenti_extended_client: pykoplenti.ExtendedApiClient, 192 | client_response_factory: Callable[ 193 | [int, Union[list[Any], dict[Any, Any]]], MagicMock 194 | ], 195 | websession: MagicMock, 196 | scope: str, 197 | ): 198 | """Test virtuel process data for energy to grid.""" 199 | client_response_factory( 200 | 200, 201 | [ 202 | { 203 | "moduleid": "scb:statistic:EnergyFlow", 204 | "processdataids": [ 205 | f"Statistic:Yield:{scope}", 206 | f"Statistic:EnergyHomeBat:{scope}", 207 | f"Statistic:EnergyHomePv:{scope}", 208 | ], 209 | }, 210 | ], 211 | ) 212 | client_response_factory( 213 | 200, 214 | [ 215 | { 216 | "moduleid": "scb:statistic:EnergyFlow", 217 | "processdata": [ 218 | { 219 | "id": f"Statistic:Yield:{scope}", 220 | "unit": "Wh", 221 | "value": 1000.0, 222 | }, 223 | { 224 | "id": f"Statistic:EnergyHomeBat:{scope}", 225 | "unit": "Wh", 226 | "value": 100.0, 227 | }, 228 | { 229 | "id": f"Statistic:EnergyHomePv:{scope}", 230 | "unit": "Wh", 231 | "value": 200.0, 232 | }, 233 | ], 234 | }, 235 | ], 236 | ) 237 | 238 | values = await pykoplenti_extended_client.get_process_data_values( 239 | "_virt_", f"Statistic:EnergyGrid:{scope}" 240 | ) 241 | 242 | websession.request.assert_has_calls( 243 | [ 244 | call("GET", ANY, headers=ANY), 245 | call( 246 | "POST", 247 | ANY, 248 | headers=ANY, 249 | json=[ 250 | { 251 | "moduleid": "scb:statistic:EnergyFlow", 252 | "processdataids": _IterableMatcher( 253 | { 254 | f"Statistic:Yield:{scope}", 255 | f"Statistic:EnergyHomeBat:{scope}", 256 | f"Statistic:EnergyHomePv:{scope}", 257 | } 258 | ), 259 | }, 260 | ], 261 | ), 262 | ], 263 | any_order=True, 264 | ) 265 | 266 | assert values == { 267 | "_virt_": pykoplenti.ProcessDataCollection( 268 | [ 269 | pykoplenti.ProcessData( 270 | id=f"Statistic:EnergyGrid:{scope}", unit="Wh", value=700.0 271 | ) 272 | ] 273 | ) 274 | } 275 | 276 | 277 | @pytest.mark.asyncio 278 | async def test_virtual_process_data_no_dc_sum( 279 | pykoplenti_extended_client: pykoplenti.ExtendedApiClient, 280 | client_response_factory: Callable[ 281 | [int, Union[list[Any], dict[Any, Any]]], MagicMock 282 | ], 283 | websession: MagicMock, 284 | ): 285 | """Test if no virtual process data is present if dependencies are missing.""" 286 | client_response_factory( 287 | 200, 288 | [ 289 | {"moduleid": "devices:local", "processdataids": ["EM_State"]}, 290 | ], 291 | ) 292 | 293 | values = await pykoplenti_extended_client.get_process_data() 294 | 295 | websession.request.assert_called_once_with( 296 | "GET", 297 | ANY, 298 | headers=ANY, 299 | ) 300 | 301 | assert values == { 302 | "_virt_": [], 303 | "devices:local": ["EM_State"], 304 | } 305 | 306 | 307 | @pytest.mark.asyncio 308 | async def test_virtual_process_data_and_normal_process_data( 309 | pykoplenti_extended_client: pykoplenti.ExtendedApiClient, 310 | client_response_factory: Callable[ 311 | [int, Union[list[Any], dict[Any, Any]]], MagicMock 312 | ], 313 | websession: MagicMock, 314 | ): 315 | """Test if virtual and non-virtual process values can be requested.""" 316 | client_response_factory( 317 | 200, 318 | [ 319 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 320 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 321 | ], 322 | ) 323 | client_response_factory( 324 | 200, 325 | [ 326 | { 327 | "moduleid": "devices:local:pv1", 328 | "processdata": [ 329 | {"id": "P", "unit": "W", "value": 700.0}, 330 | ], 331 | }, 332 | { 333 | "moduleid": "devices:local:pv2", 334 | "processdata": [ 335 | {"id": "P", "unit": "W", "value": 300.0}, 336 | ], 337 | }, 338 | ], 339 | ) 340 | 341 | values = await pykoplenti_extended_client.get_process_data_values( 342 | {"_virt_": ["pv_P"], "devices:local:pv1": ["P"], "devices:local:pv2": ["P"]} 343 | ) 344 | 345 | websession.request.assert_has_calls( 346 | [ 347 | call("GET", ANY, headers=ANY), 348 | call( 349 | "POST", 350 | ANY, 351 | headers=ANY, 352 | json=[ 353 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 354 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 355 | ], 356 | ), 357 | ], 358 | any_order=True, 359 | ) 360 | 361 | assert values == { 362 | "_virt_": pykoplenti.ProcessDataCollection( 363 | [pykoplenti.ProcessData(id="pv_P", unit="W", value=1000.0)] 364 | ), 365 | "devices:local:pv1": pykoplenti.ProcessDataCollection( 366 | [pykoplenti.ProcessData(id="P", unit="W", value=700.0)] 367 | ), 368 | "devices:local:pv2": pykoplenti.ProcessDataCollection( 369 | [pykoplenti.ProcessData(id="P", unit="W", value=300.0)] 370 | ), 371 | } 372 | 373 | 374 | @pytest.mark.asyncio 375 | async def test_virtual_process_data_not_all_requested( 376 | pykoplenti_extended_client: pykoplenti.ExtendedApiClient, 377 | client_response_factory: Callable[ 378 | [int, Union[list[Any], dict[Any, Any]]], MagicMock 379 | ], 380 | websession: MagicMock, 381 | ): 382 | """Test if not all available virtual process data are requested.""" 383 | client_response_factory( 384 | 200, 385 | [ 386 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 387 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 388 | { 389 | "moduleid": "scb:statistic:EnergyFlow", 390 | "processdataids": [ 391 | "Statistic:Yield:Total", 392 | "Statistic:EnergyHomeBat:Total", 393 | "Statistic:EnergyHomePv:Total", 394 | ], 395 | }, 396 | ], 397 | ) 398 | client_response_factory( 399 | 200, 400 | [ 401 | { 402 | "moduleid": "devices:local:pv1", 403 | "processdata": [ 404 | {"id": "P", "unit": "W", "value": 700.0}, 405 | ], 406 | }, 407 | { 408 | "moduleid": "devices:local:pv2", 409 | "processdata": [ 410 | {"id": "P", "unit": "W", "value": 300.0}, 411 | ], 412 | }, 413 | ], 414 | ) 415 | 416 | values = await pykoplenti_extended_client.get_process_data_values( 417 | {"_virt_": ["pv_P"]} 418 | ) 419 | 420 | websession.request.assert_has_calls( 421 | [ 422 | call("GET", ANY, headers=ANY), 423 | call( 424 | "POST", 425 | ANY, 426 | headers=ANY, 427 | json=[ 428 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 429 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 430 | ], 431 | ), 432 | ], 433 | any_order=True, 434 | ) 435 | 436 | assert values == { 437 | "_virt_": pykoplenti.ProcessDataCollection( 438 | [pykoplenti.ProcessData(id="pv_P", unit="W", value=1000.0)] 439 | ), 440 | } 441 | 442 | 443 | @pytest.mark.asyncio 444 | async def test_virtual_process_data_multiple_requested( 445 | pykoplenti_extended_client: pykoplenti.ExtendedApiClient, 446 | client_response_factory: Callable[ 447 | [int, Union[list[Any], dict[Any, Any]]], MagicMock 448 | ], 449 | websession: MagicMock, 450 | ): 451 | """Test if multiple virtual process data are requested.""" 452 | client_response_factory( 453 | 200, 454 | [ 455 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 456 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 457 | { 458 | "moduleid": "scb:statistic:EnergyFlow", 459 | "processdataids": [ 460 | "Statistic:Yield:Total", 461 | "Statistic:EnergyHomeBat:Total", 462 | "Statistic:EnergyHomePv:Total", 463 | ], 464 | }, 465 | ], 466 | ) 467 | client_response_factory( 468 | 200, 469 | [ 470 | { 471 | "moduleid": "devices:local:pv1", 472 | "processdata": [ 473 | {"id": "P", "unit": "W", "value": 700.0}, 474 | ], 475 | }, 476 | { 477 | "moduleid": "devices:local:pv2", 478 | "processdata": [ 479 | {"id": "P", "unit": "W", "value": 300.0}, 480 | ], 481 | }, 482 | { 483 | "moduleid": "scb:statistic:EnergyFlow", 484 | "processdata": [ 485 | { 486 | "id": "Statistic:Yield:Total", 487 | "unit": "Wh", 488 | "value": 1000.0, 489 | }, 490 | { 491 | "id": "Statistic:EnergyHomeBat:Total", 492 | "unit": "Wh", 493 | "value": 100.0, 494 | }, 495 | { 496 | "id": "Statistic:EnergyHomePv:Total", 497 | "unit": "Wh", 498 | "value": 200.0, 499 | }, 500 | ], 501 | }, 502 | ], 503 | ) 504 | 505 | values = await pykoplenti_extended_client.get_process_data_values( 506 | {"_virt_": ["pv_P", "Statistic:EnergyGrid:Total"]} 507 | ) 508 | 509 | websession.request.assert_has_calls( 510 | [ 511 | call("GET", ANY, headers=ANY), 512 | call( 513 | "POST", 514 | ANY, 515 | headers=ANY, 516 | json=_IterableMatcher( 517 | [ 518 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 519 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 520 | { 521 | "moduleid": "scb:statistic:EnergyFlow", 522 | "processdataids": _IterableMatcher( 523 | { 524 | "Statistic:Yield:Total", 525 | "Statistic:EnergyHomeBat:Total", 526 | "Statistic:EnergyHomePv:Total", 527 | } 528 | ), 529 | }, 530 | ] 531 | ), 532 | ), 533 | ], 534 | any_order=True, 535 | ) 536 | 537 | assert values == { 538 | "_virt_": pykoplenti.ProcessDataCollection( 539 | [ 540 | pykoplenti.ProcessData(id="pv_P", unit="W", value=1000.0), 541 | pykoplenti.ProcessData( 542 | id="Statistic:EnergyGrid:Total", unit="Wh", value=700.0 543 | ), 544 | ] 545 | ), 546 | } 547 | -------------------------------------------------------------------------------- /tests/test_pykoplenti.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import json 3 | from typing import Any, Callable 4 | from unittest.mock import ANY, MagicMock 5 | 6 | import pytest 7 | 8 | import pykoplenti 9 | 10 | 11 | def test_me_parsing(): 12 | raw_response = """\ 13 | { 14 | "role": "NONE", 15 | "anonymous": true, 16 | "locked": false, 17 | "permissions": [], 18 | "active": false, 19 | "authenticated": false 20 | }""" 21 | 22 | me = pykoplenti.MeData(**json.loads(raw_response)) 23 | 24 | assert me.is_locked is False 25 | assert me.is_active is False 26 | assert me.is_authenticated is False 27 | assert me.permissions == [] 28 | assert me.is_anonymous is True 29 | assert me.role == "NONE" 30 | 31 | 32 | def test_version_parsing(): 33 | raw_response = """\ 34 | { 35 | "sw_version": "01.26.09454", 36 | "name": "PUCK RESTful API", 37 | "api_version": "0.2.0", 38 | "hostname": "scb" 39 | }""" 40 | 41 | version = pykoplenti.VersionData(**json.loads(raw_response)) 42 | 43 | assert version.api_version == "0.2.0" 44 | assert version.hostname == "scb" 45 | assert version.name == "PUCK RESTful API" 46 | assert version.sw_version == "01.26.09454" 47 | 48 | 49 | def test_event_parsing(): 50 | raw_response = """\ 51 | { 52 | "description": "Reduction of AC power due to external command.", 53 | "category": "info", 54 | "is_active": false, 55 | "code": 5014, 56 | "end_time": "2023-04-29T00:45:19", 57 | "start_time": "2023-04-29T00:44:18", 58 | "group": "Information", 59 | "long_description": "Reduction of AC power due to external command." 60 | }""" 61 | 62 | event = pykoplenti.EventData(**json.loads(raw_response)) 63 | 64 | assert event.start_time == datetime(2023, 4, 29, 0, 44, 18) 65 | assert event.end_time == datetime(2023, 4, 29, 0, 45, 19) 66 | assert event.is_active is False 67 | assert event.code == 5014 68 | assert event.long_description == "Reduction of AC power due to external command." 69 | assert event.category == "info" 70 | assert event.description == "Reduction of AC power due to external command." 71 | assert event.group == "Information" 72 | 73 | 74 | def test_module_parsing(): 75 | raw_response = """\ 76 | { 77 | "id": "devices:local:powermeter", 78 | "type": "device:powermeter" 79 | }""" 80 | 81 | module = pykoplenti.ModuleData(**json.loads(raw_response)) 82 | 83 | assert module.id == "devices:local:powermeter" 84 | assert module.type == "device:powermeter" 85 | 86 | 87 | def test_process_parsing(): 88 | raw_response = """\ 89 | { 90 | "id": "Inverter:State", 91 | "unit": "", 92 | "value": 6 93 | }""" 94 | 95 | process_data = pykoplenti.ProcessData(**json.loads(raw_response)) 96 | 97 | assert process_data.id == "Inverter:State" 98 | assert process_data.unit == "" 99 | assert process_data.value == 6 100 | 101 | 102 | def test_settings_parsing(): 103 | raw_response = """\ 104 | { 105 | "min": "0", 106 | "default": null, 107 | "access": "readonly", 108 | "unit": null, 109 | "id": "Properties:PowerId", 110 | "type": "uint32", 111 | "max": "100000" 112 | }""" 113 | 114 | settings_data = pykoplenti.SettingsData(**json.loads(raw_response)) 115 | 116 | assert settings_data.unit is None 117 | assert settings_data.default is None 118 | assert settings_data.id == "Properties:PowerId" 119 | assert settings_data.max == "100000" 120 | assert settings_data.min == "0" 121 | assert settings_data.type == "uint32" 122 | assert settings_data.access == "readonly" 123 | 124 | 125 | def test_process_data_list(): 126 | json = [ 127 | {"id": "Statistic:Yield:Day", "unit": "%", "value": 1}, 128 | {"id": "Statistic:Yield:Month", "unit": "%", "value": 2}, 129 | ] 130 | 131 | assert pykoplenti.model.process_data_list(json) == [ 132 | pykoplenti.ProcessData(id="Statistic:Yield:Day", unit="%", value="1"), 133 | pykoplenti.ProcessData(id="Statistic:Yield:Month", unit="%", value="2"), 134 | ] 135 | 136 | 137 | def test_process_data_collection_indicates_length(): 138 | raw_response = ( 139 | '[{"id": "Statistic:Yield:Day", "unit": "", "value": 1}, ' 140 | '{"id": "Statistic:Yield:Month", "unit": "", "value": 2}]' 141 | ) 142 | pdc = pykoplenti.ProcessDataCollection( 143 | pykoplenti.model.process_data_list(json.loads(raw_response)) 144 | ) 145 | 146 | assert len(pdc) == 2 147 | 148 | 149 | def test_process_data_collection_index_returns_processdata(): 150 | raw_response = ( 151 | '[{"id": "Statistic:Yield:Day", "unit": "", "value": 1}, ' 152 | '{"id": "Statistic:Yield:Month", "unit": "", "value": 2}]' 153 | ) 154 | pdc = pykoplenti.ProcessDataCollection( 155 | pykoplenti.model.process_data_list(json.loads(raw_response)) 156 | ) 157 | 158 | result = pdc["Statistic:Yield:Month"] 159 | 160 | assert isinstance(result, pykoplenti.ProcessData) 161 | assert result.id == "Statistic:Yield:Month" 162 | assert result.unit == "" 163 | assert result.value == 2 164 | 165 | 166 | def test_process_data_collection_can_be_iterated(): 167 | raw_response = ( 168 | '[{"id": "Statistic:Yield:Day", "unit": "", "value": 1}, ' 169 | '{"id": "Statistic:Yield:Month", "unit": "", "value": 2}]' 170 | ) 171 | pdc = pykoplenti.ProcessDataCollection( 172 | pykoplenti.model.process_data_list(json.loads(raw_response)) 173 | ) 174 | 175 | result = list(pdc) 176 | 177 | assert result == ["Statistic:Yield:Day", "Statistic:Yield:Month"] 178 | 179 | 180 | @pytest.mark.asyncio 181 | async def test_relogin_on_401_response( 182 | pykoplenti_client: MagicMock, 183 | client_response_factory: Callable[[int, Any], MagicMock], 184 | ): 185 | """Ensures that a re-login is executed if a 401 response was returned.""" 186 | 187 | # First response returns 401 188 | client_response_factory(401, None) 189 | 190 | # Second response is successfull 191 | client_response_factory( 192 | 200, 193 | [ 194 | { 195 | "moduleid": "moda", 196 | "processdata": [{"id": "procb", "unit": "", "value": 0}], 197 | } 198 | ], 199 | ) 200 | 201 | _ = await pykoplenti_client.get_process_data_values("moda", "procb") 202 | 203 | pykoplenti_client._login.assert_awaited_once() 204 | 205 | 206 | @pytest.mark.asyncio 207 | async def test_process_data_value( 208 | pykoplenti_client: MagicMock, 209 | client_response_factory: Callable[[int, Any], MagicMock], 210 | websession: MagicMock, 211 | ): 212 | """Test if process data values could be retrieved.""" 213 | client_response_factory( 214 | 200, 215 | [ 216 | { 217 | "moduleid": "devices:local:pv1", 218 | "processdata": [ 219 | {"id": "P", "unit": "W", "value": 700.0}, 220 | ], 221 | }, 222 | { 223 | "moduleid": "devices:local:pv2", 224 | "processdata": [ 225 | {"id": "P", "unit": "W", "value": 300.0}, 226 | ], 227 | }, 228 | ], 229 | ) 230 | 231 | values = await pykoplenti_client.get_process_data_values( 232 | {"devices:local:pv1": ["P"], "devices:local:pv2": ["P"]} 233 | ) 234 | 235 | websession.request.assert_called_once_with( 236 | "POST", 237 | ANY, 238 | headers=ANY, 239 | json=[ 240 | {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, 241 | {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, 242 | ], 243 | ) 244 | 245 | assert values == { 246 | "devices:local:pv1": pykoplenti.ProcessDataCollection( 247 | [pykoplenti.ProcessData(id="P", unit="W", value=700.0)] 248 | ), 249 | "devices:local:pv2": pykoplenti.ProcessDataCollection( 250 | [pykoplenti.ProcessData(id="P", unit="W", value=300.0)] 251 | ), 252 | } 253 | -------------------------------------------------------------------------------- /tests/test_smoketest.py: -------------------------------------------------------------------------------- 1 | """Smoketest which are executed on a real inverter.""" 2 | 3 | import os 4 | import re 5 | from typing import AsyncGenerator 6 | 7 | import aiohttp 8 | import pytest 9 | import pytest_asyncio 10 | 11 | import pykoplenti 12 | 13 | 14 | @pytest_asyncio.fixture 15 | async def authenticated_client() -> AsyncGenerator[pykoplenti.ApiClient, None]: 16 | host = os.getenv("SMOKETEST_HOST", "localhost") 17 | port = int(os.getenv("SMOKETEST_PORT", 80)) 18 | password = os.getenv("SMOKETEST_PASS", "") 19 | 20 | async with aiohttp.ClientSession() as session: 21 | client = pykoplenti.ExtendedApiClient(session, host, port) 22 | await client.login(password) 23 | yield client 24 | await client.logout() 25 | 26 | 27 | @pytest.mark.skipif( 28 | os.getenv("SMOKETEST_HOST") is None, reason="Smoketest must be explicitly executed" 29 | ) 30 | class TestSmokeTests: 31 | """Contains smoke tests which are executed on a real inverter. 32 | 33 | This tests are not automatically executed because they need real HW. Please 34 | note that all checks are highl volatile because of different configuration and 35 | firmware version of the inverter. 36 | """ 37 | 38 | @pytest.mark.asyncio 39 | async def test_smoketest_me(self, authenticated_client: pykoplenti.ApiClient): 40 | """Retrieves the MeData.""" 41 | 42 | me = await authenticated_client.get_me() 43 | 44 | assert me == pykoplenti.MeData( 45 | locked=False, 46 | active=True, 47 | authenticated=True, 48 | permissions=[], 49 | anonymous=False, 50 | role="USER", 51 | ) 52 | 53 | @pytest.mark.asyncio 54 | async def test_smoketest_version(self, authenticated_client: pykoplenti.ApiClient): 55 | """Retrieves the VersionData.""" 56 | 57 | version = await authenticated_client.get_version() 58 | 59 | # version info are highly variable hence only some basic checks are performed 60 | assert len(version.hostname) > 0 61 | assert len(version.name) > 0 62 | assert re.match(r"\d+.\d+.\d+", version.api_version) is not None 63 | assert re.match(r"\d+.\d+.\d+", version.sw_version) is not None 64 | 65 | @pytest.mark.asyncio 66 | async def test_smoketest_modules(self, authenticated_client: pykoplenti.ApiClient): 67 | """Retrieves the ModuleData.""" 68 | 69 | modules = list(await authenticated_client.get_modules()) 70 | 71 | assert len(modules) >= 17 72 | assert pykoplenti.ModuleData(id="devices:local", type="device") in modules 73 | 74 | @pytest.mark.asyncio 75 | async def test_smoketest_settings(self, authenticated_client: pykoplenti.ApiClient): 76 | """Retrieves the SettingsData.""" 77 | 78 | settings = await authenticated_client.get_settings() 79 | 80 | assert "devices:local" in settings 81 | assert ( 82 | pykoplenti.SettingsData( 83 | min="0", 84 | max="32", 85 | default=None, 86 | access="readonly", 87 | unit=None, 88 | id="Branding:ProductName1", 89 | type="string", 90 | ) 91 | in settings["devices:local"] 92 | ) 93 | 94 | @pytest.mark.asyncio 95 | async def test_smoketest_setting_value1( 96 | self, authenticated_client: pykoplenti.ApiClient 97 | ): 98 | """Retrieves the setting value with variante 1.""" 99 | 100 | setting_value = await authenticated_client.get_setting_values( 101 | "devices:local", "Branding:ProductName1" 102 | ) 103 | 104 | assert setting_value == { 105 | "devices:local": {"Branding:ProductName1": "PLENTICORE plus"} 106 | } 107 | 108 | @pytest.mark.asyncio 109 | async def test_smoketest_setting_value2( 110 | self, authenticated_client: pykoplenti.ApiClient 111 | ): 112 | """Retrieves the setting value with variante 2.""" 113 | 114 | setting_value = await authenticated_client.get_setting_values( 115 | "devices:local", ["Branding:ProductName1"] 116 | ) 117 | 118 | assert setting_value == { 119 | "devices:local": {"Branding:ProductName1": "PLENTICORE plus"} 120 | } 121 | 122 | @pytest.mark.asyncio 123 | @pytest.mark.skip(reason="API endpoint is not working") 124 | async def test_smoketest_setting_value3( 125 | self, authenticated_client: pykoplenti.ApiClient 126 | ): 127 | """Retrieves the setting value with variante 3.""" 128 | 129 | setting_value = await authenticated_client.get_setting_values( 130 | "devices:local" 131 | ) 132 | 133 | assert ( 134 | setting_value["devices:local"]["Branding:ProductName1"] == "PLENTICORE plus" 135 | ) 136 | 137 | @pytest.mark.asyncio 138 | async def test_smoketest_setting_value4( 139 | self, authenticated_client: pykoplenti.ApiClient 140 | ): 141 | """Retrieves the setting value with variante 4.""" 142 | 143 | setting_value = await authenticated_client.get_setting_values( 144 | {"devices:local": ["Branding:ProductName1"]} 145 | ) 146 | 147 | assert setting_value == { 148 | "devices:local": {"Branding:ProductName1": "PLENTICORE plus"} 149 | } 150 | 151 | @pytest.mark.asyncio 152 | async def test_smoketest_process_data_value1( 153 | self, authenticated_client: pykoplenti.ApiClient 154 | ): 155 | """Retrieves process data values by using str, str variant.""" 156 | process_data = await authenticated_client.get_process_data_values( 157 | "devices:local", "EM_State" 158 | ) 159 | 160 | assert process_data.keys() == {"devices:local"} 161 | assert len(process_data["devices:local"]) == 1 162 | assert process_data["devices:local"]["EM_State"] is not None 163 | 164 | @pytest.mark.asyncio 165 | async def test_smoketest_process_data_value2( 166 | self, authenticated_client: pykoplenti.ApiClient 167 | ): 168 | """Retrieves process data values by using str, Iterable[str] variant.""" 169 | process_data = await authenticated_client.get_process_data_values( 170 | "devices:local", ["EM_State", "Inverter:State"] 171 | ) 172 | 173 | assert process_data.keys() == {"devices:local"} 174 | assert len(process_data["devices:local"]) == 2 175 | assert process_data["devices:local"]["EM_State"] is not None 176 | assert process_data["devices:local"]["Inverter:State"] is not None 177 | 178 | @pytest.mark.asyncio 179 | async def test_smoketest_process_data_value3( 180 | self, authenticated_client: pykoplenti.ApiClient 181 | ): 182 | """Retrieves process data values by using Dict[str, Iterable[str]] variant.""" 183 | process_data = await authenticated_client.get_process_data_values( 184 | { 185 | "devices:local": ["EM_State", "Inverter:State"], 186 | "scb:export": ["PortalConActive"], 187 | } 188 | ) 189 | 190 | assert process_data.keys() == {"devices:local", "scb:export"} 191 | assert len(process_data["devices:local"]) == 2 192 | assert process_data["devices:local"]["EM_State"] is not None 193 | assert process_data["devices:local"]["Inverter:State"] is not None 194 | assert len(process_data["scb:export"]) == 1 195 | assert process_data["scb:export"]["PortalConActive"] is not None 196 | 197 | @pytest.mark.asyncio 198 | async def test_smoketest_read_all_process_values( 199 | self, authenticated_client: pykoplenti.ApiClient 200 | ): 201 | """Try to read all process values and ensure no exception is thrown.""" 202 | 203 | process_data = await authenticated_client.get_process_data() 204 | 205 | for module_id, processdata_ids in process_data.items(): 206 | processdata_values = await authenticated_client.get_process_data_values( 207 | module_id, processdata_ids 208 | ) 209 | 210 | assert len(processdata_values) == 1 211 | assert module_id in processdata_values 212 | assert set(processdata_ids) == set(processdata_values[module_id]) 213 | assert all( 214 | isinstance(x.unit, str) for x in processdata_values[module_id].values() 215 | ) 216 | assert all( 217 | isinstance(x.value, float) 218 | for x in processdata_values[module_id].values() 219 | ) 220 | 221 | @pytest.mark.asyncio 222 | async def test_smoketest_read_events( 223 | self, authenticated_client: pykoplenti.ApiClient 224 | ): 225 | """Try to read events from the inverter.""" 226 | 227 | events = await authenticated_client.get_events() 228 | 229 | for event in events: 230 | assert event.start_time < event.end_time 231 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3{9,10,11,12}-pydantic{1,2} 3 | 4 | [testenv] 5 | description = Executes pytest 6 | deps = 7 | pytest~=7.4 8 | pytest-asyncio~=0.21 9 | pytest-cov~=4.1 10 | prompt-toolkit~=3.0 11 | click~=8.0 12 | pydantic1: pydantic~=1.10 13 | pydantic2: pydantic~=2.6 14 | set_env = file|.env 15 | commands = 16 | pytest -Werror 17 | --------------------------------------------------------------------------------