pax_global_header00006660000000000000000000000064147730261050014517gustar00rootroot0000000000000052 comment=6537ebae1bb94bbeab8f44cec9f73724f9df6345 stegm-pykoplenti-6537eba/000077500000000000000000000000001477302610500154505ustar00rootroot00000000000000stegm-pykoplenti-6537eba/.env000066400000000000000000000000001477302610500162270ustar00rootroot00000000000000stegm-pykoplenti-6537eba/.github/000077500000000000000000000000001477302610500170105ustar00rootroot00000000000000stegm-pykoplenti-6537eba/.github/workflows/000077500000000000000000000000001477302610500210455ustar00rootroot00000000000000stegm-pykoplenti-6537eba/.github/workflows/ci.yaml000066400000000000000000000037331477302610500223320ustar00rootroot00000000000000name: CI on: [push] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependecies run: | python -m pip install --upgrade pip python -m pip install pipenv pipenv install --dev --python ${{ matrix.python-version }} - name: Lint with ruff run: | pipenv run ruff --output-format=github . - name: Type check with mypy run: | pipenv run mypy pykoplenti/ tests/ - name: Test with pytest run: | pipenv run pytest --junitxml=junit/test-results.xml --cov pykoplenti --cov-report=xml --cov-report=html - name: Test with tox run: | pipenv run tox - name: Build package run: | pipenv run build - name: Upload packages to github uses: actions/upload-artifact@v4 with: name: dist-${{ matrix.python-version }} path: dist/* deploy: if: startsWith(github.event.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/pykoplenti permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishi steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: "3.10" - name: Install dependecies run: | python -m pip install --upgrade pip python -m pip install pipenv pipenv install --dev - name: Build package run: | pipenv run build - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 stegm-pykoplenti-6537eba/.gitignore000066400000000000000000000001751477302610500174430ustar00rootroot00000000000000/secrets /credentials* /venv __pycache__ *.egg-info build/ dist/ .tox/ pip-wheel-metadata/ .env.local coverage.xml .coverage stegm-pykoplenti-6537eba/.python-version000066400000000000000000000000051477302610500204500ustar00rootroot000000000000003.10 stegm-pykoplenti-6537eba/.vscode/000077500000000000000000000000001477302610500170115ustar00rootroot00000000000000stegm-pykoplenti-6537eba/.vscode/launch.json000066400000000000000000000010241477302610500211530ustar00rootroot00000000000000{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Start CLI", "type": "python", "request": "launch", "envFile": "${workspaceFolder}/.env.local", "program": "${workspaceFolder}/pykoplenti/cli.py", "args": ["repl"], "console": "integratedTerminal", "justMyCode": true } ] } stegm-pykoplenti-6537eba/.vscode/settings.json000066400000000000000000000001531477302610500215430ustar00rootroot00000000000000{ "python.formatting.provider": "black", "editor.formatOnSave": true, "ruff.organizeImports": true } stegm-pykoplenti-6537eba/CHANGELOG.md000066400000000000000000000042341477302610500172640ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.4.0] - 2025-04-01 ### Changed - The cli now supports installer authentication, see `--service-code` option - New credential file format for cli that supports password and service code ## Deprecated - Option `--password-file` is now deprecated. Use `--credentials` instead. ## [1.3.0] - 2024-11-13 ## Changed - Added support for pydantic 2.x (>=2.0.0) while maintaining compatibility with pydantic 1.x (>=1.9.0). - Dropped support for Python 3.8 and added 3.11/3.12. ## Fixed - Fixed error in cli for printing events. ## [1.2.2] - 2023-11-12 ## Changed - Loosen version for required package aiohttp (Dependency to Home Assistant). ## [1.2.1] - 2023-11-11 ### Changed - Downgrade pydantic to 1.x (Dependency to Home Assistant). ## [1.2.0] - 2023-11-06 ### Changed - All models are now based on pydantic - interface is still the same. - Code is refactored into separate modules - imports are still provided by using `import pykoplenti` ### Fixed - If a request is anwered with 401, an automatic re-login is triggered (like this was already the case for 400 response). ### Added - A new api client `ExtendedApiClient` was added which provides virtual process data values. See [Virtual Process Data](doc/virtual_process_data.md) for details. - Package provide type hints via `py.typed`. ## [1.1.0] ### Added - Add installer authentication - Add a new class `pykoplenti.ExtendedApiClient` which provides virtual process ids for some common missing values. ## [1.0.0] - 2021-05-04 ### Fixed - ProcessDataCollection can now return raw json response. ### Changed - Minimum Python Version is now 3.7 - Change package metadata - Changed naming to simpler unique name ### Added - new function to read events from the inverter - new sub-command `read-events` for reading events - download of log data ## [0.2.0] - 2020-11-17 ### Changed - Prepared for PyPI-Publishing - Allow reading setting values from multiple modules stegm-pykoplenti-6537eba/LICENSE000066400000000000000000000261351477302610500164640ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. stegm-pykoplenti-6537eba/Pipfile000066400000000000000000000006241477302610500167650ustar00rootroot00000000000000[[source]] url = "https://pypi.org/simple" verify_ssl = true name = "pypi" [packages] pykoplenti = {editable = true, extras = ["cli"], path = "."} [dev-packages] black = "~=23.10" isort = "~=5.12" ruff = "*" pytest = "~=7.4" pytest-asyncio = "~=0.21" pytest-cov = "~=4.1" mypy = "~=1.6" build = "~=1.0" tox = "~=4.12" [requires] python_version = "3.10" [scripts] build = "pipenv run python -m build" stegm-pykoplenti-6537eba/Pipfile.lock000066400000000000000000001765311477302610500177270ustar00rootroot00000000000000{ "_meta": { "hash": { "sha256": "d812a365485fee1583ac0102ada099968f93c5923d8f13ece493e6cdbaab72c8" }, "pipfile-spec": 6, "requires": { "python_version": "3.10" }, "sources": [ { "name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true } ] }, "default": { "aiohttp": { "hashes": [ "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168", "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb", "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5", "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f", "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc", "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c", "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29", "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4", "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc", "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc", "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63", "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e", "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d", "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a", "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60", "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38", "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b", "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2", "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53", "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5", "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4", "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96", "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58", "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa", "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321", "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae", "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce", "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8", "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194", "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c", "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf", "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d", "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869", "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b", "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52", "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528", "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5", "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1", "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4", "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8", "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d", "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7", "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5", "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54", "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3", "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5", "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c", "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29", "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3", "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747", "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672", "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5", "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11", "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca", "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768", "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6", "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2", "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533", "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6", "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266", "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d", "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec", "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5", "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1", "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b", "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679", "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283", "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb", "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b", "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3", "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051", "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511", "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e", "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d", "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542", "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f" ], "markers": "python_version >= '3.8'", "version": "==3.9.3" }, "aiosignal": { "hashes": [ "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" ], "markers": "python_version >= '3.7'", "version": "==1.3.1" }, "annotated-types": { "hashes": [ "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43", "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d" ], "markers": "python_version >= '3.8'", "version": "==0.6.0" }, "async-timeout": { "hashes": [ "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" ], "markers": "python_version < '3.11'", "version": "==4.0.3" }, "attrs": { "hashes": [ "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" ], "markers": "python_version >= '3.7'", "version": "==23.2.0" }, "click": { "hashes": [ "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" ], "markers": "python_version >= '3.7'", "version": "==8.1.7" }, "frozenlist": { "hashes": [ "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7", "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98", "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad", "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5", "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae", "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e", "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a", "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701", "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d", "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6", "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6", "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106", "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75", "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868", "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a", "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0", "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1", "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826", "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec", "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6", "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950", "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19", "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0", "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8", "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a", "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09", "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86", "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c", "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5", "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b", "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b", "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d", "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0", "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea", "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776", "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a", "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897", "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7", "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09", "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9", "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe", "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd", "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742", "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09", "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0", "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932", "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1", "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a", "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49", "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d", "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7", "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480", "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89", "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e", "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b", "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82", "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb", "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068", "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8", "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b", "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb", "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2", "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11", "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b", "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc", "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0", "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497", "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17", "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0", "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2", "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439", "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5", "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac", "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825", "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887", "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced", "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74" ], "markers": "python_version >= '3.8'", "version": "==1.4.1" }, "idna": { "hashes": [ "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" ], "markers": "python_version >= '3.5'", "version": "==3.6" }, "kostal-plenticore": { "editable": true, "extras": [ "cli" ], "path": "." }, "multidict": { "hashes": [ "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556", "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c", "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29", "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b", "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8", "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7", "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd", "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40", "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6", "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3", "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c", "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9", "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5", "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae", "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442", "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9", "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc", "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c", "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea", "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5", "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50", "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182", "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453", "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e", "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600", "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733", "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda", "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241", "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461", "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e", "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e", "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b", "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e", "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7", "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386", "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd", "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9", "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf", "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee", "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5", "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a", "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271", "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54", "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4", "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496", "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb", "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319", "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3", "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f", "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527", "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed", "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604", "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef", "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8", "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5", "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5", "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626", "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c", "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d", "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c", "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc", "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc", "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b", "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38", "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450", "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1", "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f", "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3", "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755", "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226", "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a", "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046", "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf", "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479", "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e", "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1", "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a", "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83", "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929", "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93", "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a", "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c", "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44", "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89", "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba", "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e", "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da", "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24", "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423", "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef" ], "markers": "python_version >= '3.7'", "version": "==6.0.5" }, "prompt-toolkit": { "hashes": [ "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d", "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6" ], "markers": "python_version >= '3.7'", "version": "==3.0.43" }, "pycryptodome": { "hashes": [ "sha256:06d6de87c19f967f03b4cf9b34e538ef46e99a337e9a61a77dbe44b2cbcf0690", "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7", "sha256:210ba1b647837bfc42dd5a813cdecb5b86193ae11a3f5d972b9a0ae2c7e9e4b4", "sha256:2a1250b7ea809f752b68e3e6f3fd946b5939a52eaeea18c73bdab53e9ba3c2dd", "sha256:2ab6ab0cb755154ad14e507d1df72de9897e99fd2d4922851a276ccc14f4f1a5", "sha256:3427d9e5310af6680678f4cce149f54e0bb4af60101c7f2c16fdf878b39ccccc", "sha256:3cd3ef3aee1079ae44afaeee13393cf68b1058f70576b11439483e34f93cf818", "sha256:405002eafad114a2f9a930f5db65feef7b53c4784495dd8758069b89baf68eab", "sha256:417a276aaa9cb3be91f9014e9d18d10e840a7a9b9a9be64a42f553c5b50b4d1d", "sha256:4401564ebf37dfde45d096974c7a159b52eeabd9969135f0426907db367a652a", "sha256:49a4c4dc60b78ec41d2afa392491d788c2e06edf48580fbfb0dd0f828af49d25", "sha256:5601c934c498cd267640b57569e73793cb9a83506f7c73a8ec57a516f5b0b091", "sha256:6e0e4a987d38cfc2e71b4a1b591bae4891eeabe5fa0f56154f576e26287bfdea", "sha256:76658f0d942051d12a9bd08ca1b6b34fd762a8ee4240984f7c06ddfb55eaf15a", "sha256:76cb39afede7055127e35a444c1c041d2e8d2f1f9c121ecef573757ba4cd2c3c", "sha256:8d6b98d0d83d21fb757a182d52940d028564efe8147baa9ce0f38d057104ae72", "sha256:9b3ae153c89a480a0ec402e23db8d8d84a3833b65fa4b15b81b83be9d637aab9", "sha256:a60fedd2b37b4cb11ccb5d0399efe26db9e0dd149016c1cc6c8161974ceac2d6", "sha256:ac1c7c0624a862f2e53438a15c9259d1655325fc2ec4392e66dc46cdae24d044", "sha256:acae12b9ede49f38eb0ef76fdec2df2e94aad85ae46ec85be3648a57f0a7db04", "sha256:acc2614e2e5346a4a4eab6e199203034924313626f9620b7b4b38e9ad74b7e0c", "sha256:acf6e43fa75aca2d33e93409f2dafe386fe051818ee79ee8a3e21de9caa2ac9e", "sha256:baee115a9ba6c5d2709a1e88ffe62b73ecc044852a925dcb67713a288c4ec70f", "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b", "sha256:d29daa681517f4bc318cd8a23af87e1f2a7bad2fe361e8aa29c77d652a065de4", "sha256:d5954acfe9e00bc83ed9f5cb082ed22c592fbbef86dc48b907238be64ead5c33", "sha256:ec0bb1188c1d13426039af8ffcb4dbe3aad1d7680c35a62d8eaf2a529b5d3d4f", "sha256:ec1f93feb3bb93380ab0ebf8b859e8e5678c0f010d2d78367cf6bc30bfeb148e", "sha256:f0e6d631bae3f231d3634f91ae4da7a960f7ff87f2865b2d2b831af1dfb04e9a", "sha256:f35d6cee81fa145333137009d9c8ba90951d7d77b67c79cbe5f03c7eb74d8fe2", "sha256:f47888542a0633baff535a04726948e876bf1ed880fddb7c10a736fa99146ab3", "sha256:fb3b87461fa35afa19c971b0a2b7456a7b1db7b4eba9a8424666104925b78128" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==3.20.0" }, "pydantic": { "hashes": [ "sha256:1440966574e1b5b99cf75a13bec7b20e3512e8a61b894ae252f56275e2c465ae", "sha256:ae887bd94eb404b09d86e4d12f93893bdca79d766e738528c6fa1c849f3c6bcf" ], "markers": "python_version >= '3.8'", "version": "==2.6.0" }, "pydantic-core": { "hashes": [ "sha256:06f0d5a1d9e1b7932477c172cc720b3b23c18762ed7a8efa8398298a59d177c7", "sha256:07982b82d121ed3fc1c51faf6e8f57ff09b1325d2efccaa257dd8c0dd937acca", "sha256:0f478ec204772a5c8218e30eb813ca43e34005dff2eafa03931b3d8caef87d51", "sha256:102569d371fadc40d8f8598a59379c37ec60164315884467052830b28cc4e9da", "sha256:10dca874e35bb60ce4f9f6665bfbfad050dd7573596608aeb9e098621ac331dc", "sha256:150ba5c86f502c040b822777e2e519b5625b47813bd05f9273a8ed169c97d9ae", "sha256:1661c668c1bb67b7cec96914329d9ab66755911d093bb9063c4c8914188af6d4", "sha256:1a2fe7b00a49b51047334d84aafd7e39f80b7675cad0083678c58983662da89b", "sha256:1ae8048cba95f382dba56766525abca438328455e35c283bb202964f41a780b0", "sha256:20f724a023042588d0f4396bbbcf4cffd0ddd0ad3ed4f0d8e6d4ac4264bae81e", "sha256:2133b0e412a47868a358713287ff9f9a328879da547dc88be67481cdac529118", "sha256:21e3298486c4ea4e4d5cc6fb69e06fb02a4e22089304308817035ac006a7f506", "sha256:21ebaa4bf6386a3b22eec518da7d679c8363fb7fb70cf6972161e5542f470798", "sha256:23632132f1fd608034f1a56cc3e484be00854db845b3a4a508834be5a6435a6f", "sha256:2d5bea8012df5bb6dda1e67d0563ac50b7f64a5d5858348b5c8cb5043811c19d", "sha256:300616102fb71241ff477a2cbbc847321dbec49428434a2f17f37528721c4948", "sha256:30a8259569fbeec49cfac7fda3ec8123486ef1b729225222f0d41d5f840b476f", "sha256:399166f24c33a0c5759ecc4801f040dbc87d412c1a6d6292b2349b4c505effc9", "sha256:3fac641bbfa43d5a1bed99d28aa1fded1984d31c670a95aac1bf1d36ac6ce137", "sha256:42c29d54ed4501a30cd71015bf982fa95e4a60117b44e1a200290ce687d3e640", "sha256:462d599299c5971f03c676e2b63aa80fec5ebc572d89ce766cd11ca8bcb56f3f", "sha256:4eebbd049008eb800f519578e944b8dc8e0f7d59a5abb5924cc2d4ed3a1834ff", "sha256:502c062a18d84452858f8aea1e520e12a4d5228fc3621ea5061409d666ea1706", "sha256:5317c04349472e683803da262c781c42c5628a9be73f4750ac7d13040efb5d2d", "sha256:5511f962dd1b9b553e9534c3b9c6a4b0c9ded3d8c2be96e61d56f933feef9e1f", "sha256:561be4e3e952c2f9056fba5267b99be4ec2afadc27261505d4992c50b33c513c", "sha256:601d3e42452cd4f2891c13fa8c70366d71851c1593ed42f57bf37f40f7dca3c8", "sha256:644904600c15816a1f9a1bafa6aab0d21db2788abcdf4e2a77951280473f33e1", "sha256:653a5dfd00f601a0ed6654a8b877b18d65ac32c9d9997456e0ab240807be6cf7", "sha256:694a5e9f1f2c124a17ff2d0be613fd53ba0c26de588eb4bdab8bca855e550d95", "sha256:71b4a48a7427f14679f0015b13c712863d28bb1ab700bd11776a5368135c7d60", "sha256:72bf9308a82b75039b8c8edd2be2924c352eda5da14a920551a8b65d5ee89253", "sha256:735dceec50fa907a3c314b84ed609dec54b76a814aa14eb90da31d1d36873a5e", "sha256:73802194f10c394c2bedce7a135ba1d8ba6cff23adf4217612bfc5cf060de34c", "sha256:780daad9e35b18d10d7219d24bfb30148ca2afc309928e1d4d53de86822593dc", "sha256:8655f55fe68c4685673265a650ef71beb2d31871c049c8b80262026f23605ee3", "sha256:877045a7969ace04d59516d5d6a7dee13106822f99a5d8df5e6822941f7bedc8", "sha256:87bce04f09f0552b66fca0c4e10da78d17cb0e71c205864bab4e9595122cb9d9", "sha256:8d4dfc66abea3ec6d9f83e837a8f8a7d9d3a76d25c9911735c76d6745950e62c", "sha256:8ec364e280db4235389b5e1e6ee924723c693cbc98e9d28dc1767041ff9bc388", "sha256:8fa00fa24ffd8c31fac081bf7be7eb495be6d248db127f8776575a746fa55c95", "sha256:920c4897e55e2881db6a6da151198e5001552c3777cd42b8a4c2f72eedc2ee91", "sha256:920f4633bee43d7a2818e1a1a788906df5a17b7ab6fe411220ed92b42940f818", "sha256:9795f56aa6b2296f05ac79d8a424e94056730c0b860a62b0fdcfe6340b658cc8", "sha256:98f0edee7ee9cc7f9221af2e1b95bd02810e1c7a6d115cfd82698803d385b28f", "sha256:99c095457eea8550c9fa9a7a992e842aeae1429dab6b6b378710f62bfb70b394", "sha256:99d3a433ef5dc3021c9534a58a3686c88363c591974c16c54a01af7efd741f13", "sha256:99f9a50b56713a598d33bc23a9912224fc5d7f9f292444e6664236ae471ddf17", "sha256:9c46e556ee266ed3fb7b7a882b53df3c76b45e872fdab8d9cf49ae5e91147fd7", "sha256:9f5d37ff01edcbace53a402e80793640c25798fb7208f105d87a25e6fcc9ea06", "sha256:a0b4cfe408cd84c53bab7d83e4209458de676a6ec5e9c623ae914ce1cb79b96f", "sha256:a497be217818c318d93f07e14502ef93d44e6a20c72b04c530611e45e54c2196", "sha256:ac89ccc39cd1d556cc72d6752f252dc869dde41c7c936e86beac5eb555041b66", "sha256:adf28099d061a25fbcc6531febb7a091e027605385de9fe14dd6a97319d614cf", "sha256:afa01d25769af33a8dac0d905d5c7bb2d73c7c3d5161b2dd6f8b5b5eea6a3c4c", "sha256:b1fc07896fc1851558f532dffc8987e526b682ec73140886c831d773cef44b76", "sha256:b49c604ace7a7aa8af31196abbf8f2193be605db6739ed905ecaf62af31ccae0", "sha256:b9f3e0bffad6e238f7acc20c393c1ed8fab4371e3b3bc311020dfa6020d99212", "sha256:ba07646f35e4e49376c9831130039d1b478fbfa1215ae62ad62d2ee63cf9c18f", "sha256:bd88f40f2294440d3f3c6308e50d96a0d3d0973d6f1a5732875d10f569acef49", "sha256:c0be58529d43d38ae849a91932391eb93275a06b93b79a8ab828b012e916a206", "sha256:c45f62e4107ebd05166717ac58f6feb44471ed450d07fecd90e5f69d9bf03c48", "sha256:c56da23034fe66221f2208c813d8aa509eea34d97328ce2add56e219c3a9f41c", "sha256:c94b5537bf6ce66e4d7830c6993152940a188600f6ae044435287753044a8fe2", "sha256:cebf8d56fee3b08ad40d332a807ecccd4153d3f1ba8231e111d9759f02edfd05", "sha256:d0bf6f93a55d3fa7a079d811b29100b019784e2ee6bc06b0bb839538272a5610", "sha256:d195add190abccefc70ad0f9a0141ad7da53e16183048380e688b466702195dd", "sha256:d25ef0c33f22649b7a088035fd65ac1ce6464fa2876578df1adad9472f918a76", "sha256:d6cbdf12ef967a6aa401cf5cdf47850559e59eedad10e781471c960583f25aa1", "sha256:d8c032ccee90b37b44e05948b449a2d6baed7e614df3d3f47fe432c952c21b60", "sha256:daff04257b49ab7f4b3f73f98283d3dbb1a65bf3500d55c7beac3c66c310fe34", "sha256:e83ebbf020be727d6e0991c1b192a5c2e7113eb66e3def0cd0c62f9f266247e4", "sha256:ed3025a8a7e5a59817b7494686d449ebfbe301f3e757b852c8d0d1961d6be864", "sha256:f1936ef138bed2165dd8573aa65e3095ef7c2b6247faccd0e15186aabdda7f66", "sha256:f5247a3d74355f8b1d780d0f3b32a23dd9f6d3ff43ef2037c6dcd249f35ecf4c", "sha256:fa496cd45cda0165d597e9d6f01e36c33c9508f75cf03c0a650018c5048f578e", "sha256:fb4363e6c9fc87365c2bc777a1f585a22f2f56642501885ffc7942138499bf54", "sha256:fb4370b15111905bf8b5ba2129b926af9470f014cb0493a67d23e9d7a48348e8", "sha256:fbec2af0ebafa57eb82c18c304b37c86a8abddf7022955d1742b3d5471a6339e" ], "markers": "python_version >= '3.8'", "version": "==2.16.1" }, "pykoplenti": { "editable": true, "extras": [ "cli" ], "path": "." }, "typing-extensions": { "hashes": [ "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" ], "markers": "python_version >= '3.8'", "version": "==4.9.0" }, "wcwidth": { "hashes": [ "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" ], "version": "==0.2.13" }, "yarl": { "hashes": [ "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51", "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce", "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559", "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0", "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81", "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc", "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4", "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c", "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130", "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136", "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e", "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec", "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7", "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1", "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455", "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099", "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129", "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10", "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142", "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98", "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa", "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7", "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525", "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c", "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9", "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c", "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8", "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b", "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf", "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23", "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd", "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27", "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f", "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece", "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434", "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec", "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff", "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78", "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d", "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863", "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53", "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31", "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15", "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5", "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b", "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57", "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3", "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1", "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f", "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad", "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c", "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7", "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2", "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b", "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2", "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b", "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9", "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be", "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e", "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984", "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4", "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074", "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2", "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392", "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91", "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541", "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf", "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572", "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66", "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575", "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14", "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5", "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1", "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e", "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551", "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17", "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead", "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0", "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe", "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234", "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0", "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7", "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34", "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42", "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385", "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78", "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be", "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958", "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749", "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec" ], "markers": "python_version >= '3.7'", "version": "==1.9.4" } }, "develop": { "black": { "hashes": [ "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50", "sha256:1fa88a0f74e50e4487477bc0bb900c6781dbddfdfa32691e780bf854c3b4a47f", "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e", "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec", "sha256:3e1b38b3135fd4c025c28c55ddfc236b05af657828a8a6abe5deec419a0b7055", "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3", "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5", "sha256:4f0031eaa7b921db76decd73636ef3a12c942ed367d8c3841a0739412b260a54", "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b", "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e", "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e", "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba", "sha256:97e56155c6b737854e60a9ab1c598ff2533d57e7506d97af5481141671abf3ea", "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59", "sha256:a4d6a9668e45ad99d2f8ec70d5c8c04ef4f32f648ef39048d010b0689832ec6d", "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0", "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9", "sha256:b18fb2ae6c4bb63eebe5be6bd869ba2f14fd0259bda7d18a46b764d8fb86298a", "sha256:c04b6d9d20e9c13f43eee8ea87d44156b8505ca8a3c878773f68b4e4812a421e", "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba", "sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2", "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2" ], "index": "pypi", "version": "==23.12.1" }, "build": { "hashes": [ "sha256:538aab1b64f9828977f84bc63ae570b060a8ed1be419e7870b8b4fc5e6ea553b", "sha256:589bf99a67df7c9cf07ec0ac0e5e2ea5d4b37ac63301c4986d1acb126aa83f8f" ], "index": "pypi", "version": "==1.0.3" }, "cachetools": { "hashes": [ "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2", "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1" ], "markers": "python_version >= '3.7'", "version": "==5.3.2" }, "chardet": { "hashes": [ "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" ], "markers": "python_version >= '3.7'", "version": "==5.2.0" }, "click": { "hashes": [ "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" ], "markers": "python_version >= '3.7'", "version": "==8.1.7" }, "colorama": { "hashes": [ "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", "version": "==0.4.6" }, "coverage": { "extras": [ "toml" ], "hashes": [ "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61", "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1", "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7", "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7", "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75", "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd", "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35", "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04", "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6", "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042", "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166", "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1", "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d", "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c", "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66", "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70", "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1", "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676", "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630", "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a", "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74", "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad", "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19", "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6", "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448", "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018", "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218", "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756", "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54", "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45", "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628", "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968", "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d", "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25", "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60", "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950", "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06", "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295", "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b", "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c", "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc", "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74", "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1", "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee", "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011", "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156", "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766", "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5", "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581", "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016", "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c", "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3" ], "markers": "python_full_version >= '3.8.0'", "version": "==7.4.1" }, "distlib": { "hashes": [ "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64" ], "version": "==0.3.8" }, "exceptiongroup": { "hashes": [ "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" ], "markers": "python_version < '3.11'", "version": "==1.2.0" }, "filelock": { "hashes": [ "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e", "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c" ], "markers": "python_full_version >= '3.8.0'", "version": "==3.13.1" }, "iniconfig": { "hashes": [ "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" ], "markers": "python_version >= '3.7'", "version": "==2.0.0" }, "isort": { "hashes": [ "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" ], "index": "pypi", "version": "==5.13.2" }, "mypy": { "hashes": [ "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6", "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d", "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02", "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d", "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3", "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3", "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3", "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66", "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259", "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835", "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd", "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d", "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8", "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07", "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b", "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e", "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6", "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae", "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9", "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d", "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a", "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592", "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218", "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817", "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4", "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410", "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55" ], "index": "pypi", "version": "==1.8.0" }, "mypy-extensions": { "hashes": [ "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" ], "markers": "python_version >= '3.5'", "version": "==1.0.0" }, "packaging": { "hashes": [ "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" ], "markers": "python_version >= '3.7'", "version": "==23.2" }, "pathspec": { "hashes": [ "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" ], "markers": "python_full_version >= '3.8.0'", "version": "==0.12.1" }, "platformdirs": { "hashes": [ "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" ], "markers": "python_full_version >= '3.8.0'", "version": "==4.2.0" }, "pluggy": { "hashes": [ "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" ], "markers": "python_full_version >= '3.8.0'", "version": "==1.4.0" }, "pyproject-api": { "hashes": [ "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538", "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675" ], "markers": "python_full_version >= '3.8.0'", "version": "==1.6.1" }, "pyproject-hooks": { "hashes": [ "sha256:283c11acd6b928d2f6a7c73fa0d01cb2bdc5f07c57a2eeb6e83d5e56b97976f8", "sha256:f271b298b97f5955d53fb12b72c1fb1948c22c1a6b70b315c54cedaca0264ef5" ], "markers": "python_version >= '3.7'", "version": "==1.0.0" }, "pytest": { "hashes": [ "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8" ], "index": "pypi", "version": "==7.4.4" }, "pytest-asyncio": { "hashes": [ "sha256:2143d9d9375bf372a73260e4114541485e84fca350b0b6b92674ca56ff5f7ea2", "sha256:b0079dfac14b60cd1ce4691fbfb1748fe939db7d0234b5aba97197d10fbe0fef" ], "index": "pypi", "version": "==0.23.4" }, "pytest-cov": { "hashes": [ "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" ], "index": "pypi", "version": "==4.1.0" }, "ruff": { "hashes": [ "sha256:30ad74687e1f4a9ff8e513b20b82ccadb6bd796fe5697f1e417189c5cde6be3e", "sha256:3826fb34c144ef1e171b323ed6ae9146ab76d109960addca730756dc19dc7b22", "sha256:3d3c641f95f435fc6754b05591774a17df41648f0daf3de0d75ad3d9f099ab92", "sha256:3fbaff1ba9564a2c5943f8f38bc221f04bac687cc7485e45237579fee7ccda79", "sha256:3ff35433fcf4dff6d610738712152df6b7d92351a1bde8e00bd405b08b3d5759", "sha256:63856b91837606c673537d2889989733d7dffde553828d3b0f0bacfa6def54be", "sha256:638ea3294f800d18bae84a492cb5a245c8d29c90d19a91d8e338937a4c27fca0", "sha256:6d232f99d3ab00094ebaf88e0fb7a8ccacaa54cc7fa3b8993d9627a11e6aed7a", "sha256:8153a3e4128ed770871c47545f1ae7b055023e0c222ff72a759f5a341ee06483", "sha256:87057dd2fdde297130ff99553be8549ca38a2965871462a97394c22ed2dfc19d", "sha256:a7e3818698f8460bd0f8d4322bbe99db8327e9bc2c93c789d3159f5b335f47da", "sha256:ba918e01cdd21e81b07555564f40d307b0caafa9a7a65742e98ff244f5035c59", "sha256:bf9faafbdcf4f53917019f2c230766da437d4fd5caecd12ddb68bb6a17d74399", "sha256:e155147199c2714ff52385b760fe242bb99ea64b240a9ffbd6a5918eb1268843", "sha256:e8a75a98ae989a27090e9c51f763990ad5bbc92d20626d54e9701c7fe597f399", "sha256:eceab7d85d09321b4de18b62d38710cf296cb49e98979960a59c6b9307c18cfe", "sha256:edf23041242c48b0d8295214783ef543847ef29e8226d9f69bf96592dba82a83" ], "index": "pypi", "version": "==0.2.0" }, "tomli": { "hashes": [ "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" ], "markers": "python_version < '3.11'", "version": "==2.0.1" }, "tox": { "hashes": [ "sha256:61aafbeff1bd8a5af84e54ef6e8402f53c6a6066d0782336171ddfbf5362122e", "sha256:c07ea797880a44f3c4f200ad88ad92b446b83079d4ccef89585df64cc574375c" ], "index": "pypi", "version": "==4.12.1" }, "typing-extensions": { "hashes": [ "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" ], "markers": "python_version >= '3.8'", "version": "==4.9.0" }, "virtualenv": { "hashes": [ "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3", "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b" ], "markers": "python_version >= '3.7'", "version": "==20.25.0" } } } stegm-pykoplenti-6537eba/README.md000066400000000000000000000120121477302610500167230ustar00rootroot00000000000000# Python Library for Accessing Kostal Plenticore Inverters This repository provides a python library and command line interface for the REST-API of Kostal Plenticore Solar Inverter. 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/). ![CI](https://github.com/stegm/pykoplenti/workflows/CI/badge.svg) ## Features - Authenticate - Read/Write settings - Read process data - Read events - Download of log data - Full async-Support for reading and writing data - [Commandline interface](doc/command_line.md) for shell access - Dynamic data model - adapts automatically to new process data or settings - [Virtual Process Data](doc/virtual_process_data.md) values ## Getting Started ### Prerequisites You will need Python >=3.7. ### Installing the library Packages of this library are released on [PyPI](https://pypi.org/project/kostal-plenticore/) and can be installed with `pip`. Alternatively the packages can also be downloaded from [GitHub](https://github.com/stegm/pykoplenti/releases/). I recommend to use a [virtual environment](https://docs.python.org/3/library/venv.html) for this, because it installs the dependecies independently from the system. The installed CLI tools can then be called without activating the virtual environment it. ```shell # Install with command line support $ pip install pykoplenti[CLI] # Install without command line support $ pip install pykoplenti ``` ### Using the command line interface Installing the libray with `CLI` provides a new command. ```shell $ pykoplenti --help Usage: python -m pykoplenti.cli [OPTIONS] COMMAND [ARGS]... Handling of global arguments with click Options: --host TEXT Hostname or IP of the inverter --port INTEGER Port of the inverter [default: 80] --password TEXT Password or master key (also device id) --service-code TEXT service code for installer access --password-file FILE Path to password file - deprecated, use --credentials [default: secrets] --credentials FILE Path to the credentials file. This has a simple ini- format without sections. For user access, use the 'password'. For installer access, use the 'master-key' and 'service-key'. --help Show this message and exit. Commands: all-processdata Returns a list of all available process data. all-settings Returns the ids of all settings. download-log Download the log data from the inverter to a file. read-events Returns the last events read-processdata Returns the values of the given process data. read-settings Read the value of the given settings. repl Provides a simple REPL for executing API requests to... write-settings Write the values of the given settings. ``` Visit [Command Line Help](doc/command_line.md) for example usage. ### Using the library from python The library is fully async, there for you need an async loop and an async `ClientSession`. Please refer to the example directory for full code. Import the client module: ```python from pykoplenti import ApiClient ``` To communicate with the inverter you need to instantiate the client: ```python # session is a aiohttp ClientSession client = ApiClient(session, '192.168.1.100') ``` Login to gain full access to process data and settings: ```python await client.login(passwd) ``` Now you can access the API. For example to read process data values: ```python data = await client.get_process_data_values('devices:local', ['Inverter:State', 'Home_P']) device_local = data['devices:local'] inverter_state = device_local['Inverter:State'] home_p = device_local['Home_P'] ``` See the full example here: [read_process_data.py](examples/read_process_data.py). If you should need installer access use the master key (printed on a label at the side of the inverter) and additionally pass your service code: ```python await client.login(my_master_key, service_code=my_service_code) ``` ## Documentation - [Command Line Interface](doc/command_line.md) - [Examples](examples/) - [Virtual Process Data](doc/virtual_process_data.md) - [Notes about Process Data](doc/process_data.md) ## Built With - [AIOHTTPO](https://docs.aiohttp.org/en/stable/) - asyncio for HTTP - [click](https://click.palletsprojects.com/) - command line interface framework - [black](https://github.com/psf/black) - Python code formatter - [ruff](https://github.com/astral-sh/ruff) - Python linter - [pydantic](https://docs.pydantic.dev/latest/) - Data validation library - [pytest](https://docs.pytest.org/) - Python test framework - [mypy](https://mypy-lang.org/) - Python type checker - [setuptools](https://github.com/pypa/setuptools) - Python packager - [tox](https://tox.wiki) - Automate testing ## License apache-2.0 ## Acknowledgments - [kilianknoll](https://github.com/kilianknoll) for the kostal-RESTAPI project stegm-pykoplenti-6537eba/doc/000077500000000000000000000000001477302610500162155ustar00rootroot00000000000000stegm-pykoplenti-6537eba/doc/command_line.md000066400000000000000000000074621477302610500211750ustar00rootroot00000000000000# Command Line Interface ## Shell-Commands 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). The credentials file is a text file containing at least the following line: ``` password= ``` If you want to use installer authentication instead, the file should contain two lines: ``` master-key= service-code= ``` Alternatively, `--password` and `--service-code` arguments can be used. 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. ### Display all available process data id's ```shell script $ pykoplenti --host 192.168.1.100 --password verysecret all-processdata devices:local/Dc_P devices:local/DigitalIn devices:local/EM_State devices:local/Grid_L1_I devices:local/Grid_L1_P ~~~ scb:statistic:EnergyFlow/Statistic:Yield:Month scb:statistic:EnergyFlow/Statistic:Yield:Total scb:statistic:EnergyFlow/Statistic:Yield:Year ``` The returned ids can be used to query process data values. ### Read process data values **Read a single value** ```shell script $ pykoplenti --host 192.168.1.100 --password verysecret read-processdata devices:local/Inverter:State devices:local/Inverter:State=6.0 ``` **Read multiple values (even on different modules)** ```shell script $ pykoplenti --host 192.168.1.100 --password verysecret read-processdata devices:local/Inverter:State devices:local/EM_State devices:local:pv1/U devices:local/EM_State=0.0 devices:local/Inverter:State=6.0 devices:local:pv1/U=11.0961999893 ``` This is the most efficient way because all process data are fetched with a single HTTP request. **Read all values off a module** ```shell script $ pykoplenti --host 192.168.1.100 --password verysecret read-processdata devices:local:pv1 devices:local:pv1/I=0.0058542006 devices:local:pv1/P=-0.11253988 devices:local:pv1/U=10.9401073456 ``` ### Display all available setting id's **Display all setting id's** ```shell script $ pykoplenti --host 192.168.1.100 --password verysecret all-settings devices:local/ActivePower:ExtCtrl:Enable devices:local/ActivePower:ExtCtrl:ModeGradientEnable devices:local/ActivePower:ExtCtrl:ModeGradientFactor ~~~ scb:time/NTPservers scb:time/NTPuse scb:time/Timezone ``` **Display only writable setting id's** ```shell script $ pykoplenti --host 192.168.1.100 --password verysecret all-settings --rw devices:local/Battery:BackupMode:Enable devices:local/Battery:DynamicSoc:Enable devices:local/Battery:MinHomeComsumption ~~~ scb:time/NTPservers scb:time/NTPuse scb:time/Timezone ``` ### Reading setting values **Read a single setting value** ```shell script $ pykoplenti --host 192.168.1.100 --password verysecret read-settings scb:time/Timezone scb:time/Timezone=Europe/Berlin ``` **Read multiple setting values** ```shell script $ pykoplenti --host 192.168.1.100 --password verysecret read-settings scb:time/Timezone scb:network/Hostname scb:time/Timezone=Europe/Berlin scb:network/Hostname=scb ``` ### Writing setting values ```shell script $ pykoplenti --host 192.168.1.100 --password verysecret write-settings devices:local/Battery:MinSoc=10 ``` ### REPL A REPL is provided for simple interactive tests. All methods of the `ApiClient` class can be called. The arguments must be given separated by spaces by using python literals. ```shell script $ pykoplenti --host 192.168.1.100 repl (pykoplenti)> get_me Me(locked=False, active=False, authenticated=False, permissions=[] anonymous=True role=NONE) (pykoplenti)> get_process_data_values "devices:local" "Inverter:State" devices:local: ProcessData(id=Inverter:State, unit=, value=6.0) ```stegm-pykoplenti-6537eba/doc/developer.md000066400000000000000000000032311477302610500205230ustar00rootroot00000000000000# Developer Notes ## Code Format ```shell script isort pykoplenti black --fast pykoplenti ``` ## Initialize developer environment with pipenv ```shell script pipenv sync --dev ``` ## Run pytest using tox `tox` is configured to run pytest with different versions of pydantic. Run all environemnts: ```shell script tox ``` Available environments: * `py39-pydantic1` - Python 3.9 with Pydantic 1.x * `py39-pydantic2` - Python 3.9 with Pydantic 2.x * `py310-pydantic1` - Python 3.10 with Pydantic 1.x * `py310-pydantic2` - Python 3.10 with Pydantic 2.x * `py311-pydantic1` - Python 3.11 with Pydantic 1.x * `py311-pydantic2` - Python 3.11 with Pydantic 2.x * `py312-pydantic1` - Python 3.12 with Pydantic 1.x * `py312-pydantic2` - Python 3.12 with Pydantic 2.x If `tox` should use `pyenv`, the package `tox-pyenv-redux` must be installed manually. It cannot be installed in pipenv dev, because it is incompatible with github actions. ## Running smoke tests The test suite contains some smoke tests that connect directly to an inverter and attempt to retrieve data from it. These tests are normally disabled but can be enabled by setting some environment variables before running `pytest`. It is recommended to set these variables in `.env` where `pipenv` reads them before executing a command. | Variable | Description | | ---------------- | ----------------------------------------------------- | | SMOKETEST_HOST | The ip or host of the inverter. | | SMOKETEST_PORT | The port of the web API of the inverter (default: 80) | | SMOKETEST_PASS | The password of the web UI | stegm-pykoplenti-6537eba/doc/process_data.md000066400000000000000000000015351477302610500212120ustar00rootroot00000000000000# Process Data Here are some notes about process data which might not be clear at first: | Process Data | Modbus | Description | Comment | |--------------|--------|-------------|---------| | devices:local/Dc_P | 0x64 | Total DC power | This also includes battery | | scb:statistic:EnergyFlow/Statistic:EnergyChargePv:Total | 0x416 | Total DC charge energy (DC-side to battery) | | | scb:statistic:EnergyFlow/Statistic:EnergyDischarge:Total | 0x418 | Total DC discharge energy (DC-side from battery) | | | scb:statistic:EnergyFlow/Statistic:EnergyChargeGrid:Total | 0x41A | Total AC charge energy (AC-side to battery) | | | scb:statistic:EnergyFlow/Statistic:EnergyDischargeGrid:Total | 0x41C | Total AC discharge energy (battery to grid) | | | scb:statistic:EnergyFlow/Statistic:EnergyChargeInvIn:Total | 0x41E | Total AC charge energy (grid to battery) | | stegm-pykoplenti-6537eba/doc/virtual_process_data.md000066400000000000000000000022731477302610500227600ustar00rootroot00000000000000# Virtual Process Data 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. All virtual process items are in the module `_virt_`. Note: This feature is experimental and might change in the next version. | process id | description | |-----------------------------|-------------| | pv_P | Sum of all PV DC inputs (from `devices:local:pv1/P` + `devices:local:pv2/P` + `devices:local:pv3/P`) | | Statistic:EnergyGrid:Total | Total energy delivered to grid (from `Statistic:Yield:Total` - `Statistic:EnergyHomeBat:Total` - `Statistic:EnergyHomePv:Total`) | | Statistic:EnergyGrid:Year | Total energy delivered to grid (from `Statistic:Yield:Year` - `Statistic:EnergyHomeBat:Year` - `Statistic:EnergyHomePv:Year`) | | Statistic:EnergyGrid:Month | Total energy delivered to grid (from `Statistic:Yield:Month` - `Statistic:EnergyHomeBat:Month` - `Statistic:EnergyHomePv:Month`) | | Statistic:EnergyGrid:Day | Total energy delivered to grid (from `Statistic:Yield:Day` - `Statistic:EnergyHomeBat:Day` - `Statistic:EnergyHomePv:Day`) | stegm-pykoplenti-6537eba/examples/000077500000000000000000000000001477302610500172665ustar00rootroot00000000000000stegm-pykoplenti-6537eba/examples/read_process_data.py000066400000000000000000000016501477302610500233040ustar00rootroot00000000000000import asyncio import sys from aiohttp import ClientSession from pykoplenti import ApiClient """ Provides a simple example which reads two process data from the plenticore. Must be called with host and password: `python read_process_data.py 192.168.1.100 mysecretpassword` """ async def async_main(host, passwd): async with ClientSession() as session: client = ApiClient(session, host) await client.login(passwd) data = await client.get_process_data_values( "devices:local", ["Inverter:State", "Home_P"] ) device_local = data["devices:local"] inverter_state = device_local["Inverter:State"] home_p = device_local["Home_P"] print(f"Inverter-State: {inverter_state.value}\nHome-P: {home_p.value}\n") if len(sys.argv) != 3: print("Usage: ") sys.exit(1) _, host, passwd = sys.argv asyncio.run(async_main(host, passwd)) stegm-pykoplenti-6537eba/examples/read_virtual_process_data.py000066400000000000000000000014021477302610500250450ustar00rootroot00000000000000import asyncio import sys from aiohttp import ClientSession from pykoplenti import ExtendedApiClient """ Provides a simple example which reads virtual process data from the plenticore. Must be called with host and password: `python read_virtual_process_data.py 192.168.1.100 mysecretpassword` """ async def async_main(host, passwd): async with ClientSession() as session: client = ExtendedApiClient(session, host) await client.login(passwd) data = await client.get_process_data_values("_virt_", "pv_P") pv_power = data["_virt_"]["pv_P"] print(f"PV power: {pv_power}") if len(sys.argv) != 3: print("Usage: ") sys.exit(1) _, host, passwd = sys.argv asyncio.run(async_main(host, passwd)) stegm-pykoplenti-6537eba/pykoplenti/000077500000000000000000000000001477302610500176465ustar00rootroot00000000000000stegm-pykoplenti-6537eba/pykoplenti/__init__.py000066400000000000000000000013641477302610500217630ustar00rootroot00000000000000from .api import ( ApiClient, ApiException, AuthenticationException, InternalCommunicationException, ModuleNotFoundException, NotAuthorizedException, UserLockedException, ) from .extended import ExtendedApiClient from .model import ( EventData, MeData, ModuleData, ProcessData, ProcessDataCollection, SettingsData, VersionData, ) __all__ = [ "MeData", "VersionData", "ModuleData", "ProcessData", "ProcessDataCollection", "SettingsData", "EventData", "ApiException", "InternalCommunicationException", "AuthenticationException", "NotAuthorizedException", "UserLockedException", "ModuleNotFoundException", "ApiClient", "ExtendedApiClient", ] stegm-pykoplenti-6537eba/pykoplenti/api.py000066400000000000000000000627771477302610500210140ustar00rootroot00000000000000from base64 import b64decode, b64encode from collections.abc import Mapping import contextlib from datetime import datetime import functools import hashlib import hmac import locale import logging from os import urandom from typing import IO, Dict, Final, Iterable, List, Union, overload import warnings from Crypto.Cipher import AES from aiohttp import ClientResponse, ClientSession, ClientTimeout from yarl import URL from .model import ( EventData, MeData, ModuleData, ProcessDataCollection, SettingsData, VersionData, process_data_list, ) _logger: Final = logging.getLogger(__name__) class ApiException(Exception): """Base exception for API calls.""" def __init__(self, msg): self.msg = msg def __str__(self): return f"API Error: {self.msg}" class InternalCommunicationException(ApiException): """Exception for internal communication error response.""" def __init__(self, status_code: int, error: str): super().__init__(f"Internal communication error ([{status_code}] - {error})") self.status_code = status_code self.error = error class AuthenticationException(ApiException): """Exception for authentication or user error response.""" def __init__(self, status_code: int, error: str): super().__init__( f"Invalid user/Authentication failed ([{status_code}] - {error})" ) self.status_code = status_code self.error = error class NotAuthorizedException(ApiException): """Exception for calles without authentication.""" def __init__(self, status_code: int, error: str): super().__init__(f"Not authorized ([{status_code}] - {error})") self.status_code = status_code self.error = error class UserLockedException(ApiException): """Exception for user locked error response.""" def __init__(self, status_code: int, error: str): super().__init__(f"User is locked ([{status_code}] - {error})") self.status_code = status_code self.error = error class ModuleNotFoundException(ApiException): """Exception for module or setting not found response.""" def __init__(self, status_code: int, error: str): super().__init__(f"Module or setting not found ([{status_code}] - {error})") self.status_code = status_code self.error = error def _relogin(fn): """Decorator for automatic re-login if session was expired.""" @functools.wraps(fn) async def _wrapper(self: "ApiClient", *args, **kwargs): with contextlib.suppress(AuthenticationException, NotAuthorizedException): return await fn(self, *args, **kwargs) _logger.debug("Request failed - try to re-login") await self._login() return await fn(self, *args, **kwargs) return _wrapper class ApiClient(contextlib.AbstractAsyncContextManager): """Client for the REST-API of Kostal Plenticore inverters. The RESP-API provides several scopes of information. Each scope provides a dynamic set of data which can be retrieved using this interface. The scopes are: - process data (readonly, dynamic values of the operation) - settings (some are writable, static values for configuration) The data are grouped into modules. For example the module `devices:local` provides a process data `Dc_P` which contains the value of the current DC power. To get all process data or settings the methods `get_process_data` or `get_settings` can be used. Depending of the current logged in user the returned data can vary. The methods `get_process_data_values` and `get_setting_values` can be used to read process data or setting values from the inverter. You can use `set_setting_values` to write new setting values to the inverter if the setting is writable. The authorization system of the inverter comprises three states: * not logged in (is_active=False, authenticated=False) * logged in and active (is_active=True, authenticated=True) * logged in and inactive (is_active=False, authenticated=False) The current state can be queried with the `get_me` method. Depending of this state some operation might not be available. """ BASE_URL = "/api/v1/" SUPPORTED_LANGUAGES = { "de": ["de"], "en": ["gb"], "es": ["es"], "fr": ["fr"], "hu": ["hu"], "it": ["it"], "nl": ["nl"], "pl": ["pl"], "pt": ["pt"], "cs": ["cz"], "el": ["gr"], "zh": ["cn"], } def __init__(self, websession: ClientSession, host: str, port: int = 80): """Create a new client. :param websession: A aiohttp ClientSession for all requests :param host: The hostname or ip of the inverter :param port: The port of the API interface (default 80) """ self.websession = websession self.host = host self.port = port self.session_id: Union[str, None] = None self._key: Union[str, None] = None self._service_code: Union[str, None] = None self._user: Union[str, None] = None async def __aexit__(self, exc_type, exc_value, traceback): """Logout support for context manager.""" if self.session_id is not None: await self.logout() def _create_url(self, path: str) -> URL: """Creates a REST-API URL with the given path as suffix. :param path: path suffix, must not start with '/' :return: a URL instance """ base = URL.build( scheme="http", host=self.host, port=self.port, path=ApiClient.BASE_URL, ) return base.join(URL(path)) async def initialize_virtual_process_data(self): process_data = await self.get_process_data() self._virt_process_data.initialize(process_data) async def login( self, key: str, service_code: Union[str, None] = None, password: Union[str, None] = None, user: Union[str, None] = None, ): """Login with the given password (key). If a service code is provided user is 'master', else 'user'. Parameters ---------- :param key: The user password. If 'service_code' is given, 'key' is the Master Key (also called Device ID). :type key: str, None :param service_code: Installer service code. If given the user is assumed to be 'master', else 'user'. :type service_code: str, None :param password: Deprecated, use key instead. :param user: Deprecated, user is chosen automatically depending on service_code. :raises AuthenticationException: if authentication failed :raises aiohttp.client_exceptions.ClientConnectorError: if host is not reachable :raises asyncio.exceptions.TimeoutError: if a timeout occurs """ if password is None: self._key = key else: warnings.warn( "password is deprecated. Use key instead.", DeprecationWarning ) self._key = password if user is None: self._user = "master" if service_code else "user" else: warnings.warn( "user is deprecated. user is chosen automatically.", DeprecationWarning ) self._service_code = service_code try: await self._login() except Exception: self._key = None self._user = None self._service_code = None raise async def _login(self): # Step 1 start authentication client_nonce = urandom(12) start_request = { "username": self._user, "nonce": b64encode(client_nonce).decode("utf-8"), } async with self.websession.request( "POST", self._create_url("auth/start"), json=start_request ) as resp: await self._check_response(resp) start_response = await resp.json() server_nonce = b64decode(start_response["nonce"]) transaction_id = b64decode(start_response["transactionId"]) salt = b64decode(start_response["salt"]) rounds = start_response["rounds"] # Step 2 finish authentication (RFC5802) salted_passwd = hashlib.pbkdf2_hmac( "sha256", self._key.encode("utf-8"), salt, rounds ) client_key = hmac.new( salted_passwd, "Client Key".encode("utf-8"), hashlib.sha256 ).digest() stored_key = hashlib.sha256(client_key).digest() auth_msg = ( "n={user},r={client_nonce},r={server_nonce},s={salt},i={rounds}," "c=biws,r={server_nonce}".format( user=self._user, client_nonce=b64encode(client_nonce).decode("utf-8"), server_nonce=b64encode(server_nonce).decode("utf-8"), salt=b64encode(salt).decode("utf-8"), rounds=rounds, ) ) client_signature = hmac.new( stored_key, auth_msg.encode("utf-8"), hashlib.sha256 ).digest() client_proof = bytes(a ^ b for a, b in zip(client_key, client_signature)) server_key = hmac.new( salted_passwd, "Server Key".encode("utf-8"), hashlib.sha256 ).digest() server_signature = hmac.new( server_key, auth_msg.encode("utf-8"), hashlib.sha256 ).digest() finish_request = { "transactionId": b64encode(transaction_id).decode("utf-8"), "proof": b64encode(client_proof).decode("utf-8"), } async with self.websession.request( "POST", self._create_url("auth/finish"), json=finish_request ) as resp: await self._check_response(resp) finish_response = await resp.json() token = finish_response["token"] signature = b64decode(finish_response["signature"]) if signature != server_signature: raise Exception("Server signature mismatch.") # Step 3 create session session_key_hmac = hmac.new( stored_key, "Session Key".encode("utf-8"), hashlib.sha256 ) session_key_hmac.update(auth_msg.encode("utf-8")) session_key_hmac.update(client_key) protocol_key = session_key_hmac.digest() session_nonce = urandom(16) cipher = AES.new(protocol_key, AES.MODE_GCM, nonce=session_nonce) if self._user == "master": token = f"{token}:{self._service_code}" cipher_text, auth_tag = cipher.encrypt_and_digest(token.encode("utf-8")) session_request = { # AES initialization vector "iv": b64encode(session_nonce).decode("utf-8"), # AES GCM tag "tag": b64encode(auth_tag).decode("utf-8"), # ID of authentication transaction "transactionId": b64encode(transaction_id).decode("utf-8"), # Only the token or token and service code (separated by colon). Encrypted # with AES using the protocol key "payload": b64encode(cipher_text).decode("utf-8"), } async with self.websession.request( "POST", self._create_url("auth/create_session"), json=session_request ) as resp: await self._check_response(resp) session_response = await resp.json() self.session_id = session_response["sessionId"] def _session_request(self, path: str, method="GET", **kwargs): """Make an request on the current active session. :param path: the URL suffix :param method: the request method, defaults to 'GET' :param **kwargs: all other args are forwarded to the request """ headers: Dict[str, str] = {} if self.session_id is not None: headers["authorization"] = f"Session {self.session_id}" return self.websession.request( method, self._create_url(path), headers=headers, **kwargs ) async def _check_response(self, resp: ClientResponse): """Check if the given response contains an error and throws the appropriate exception.""" if resp.status == 200: return try: response = await resp.json() error = response["message"] except Exception: error = None if resp.status == 400: raise AuthenticationException(resp.status, error) if resp.status == 401: raise NotAuthorizedException(resp.status, error) if resp.status == 403: raise UserLockedException(resp.status, error) if resp.status == 404: raise ModuleNotFoundException(resp.status, error) if resp.status == 503: raise InternalCommunicationException(resp.status, error) # we got an undocumented status code raise ApiException(f"Unknown API response [{resp.status}] - {error}") async def logout(self): """Logs the current user out.""" self._key = None self._service_code = None async with self._session_request("auth/logout", method="POST") as resp: await self._check_response(resp) async def get_me(self) -> MeData: """Returns information about the user. No login is required. """ async with self._session_request("auth/me") as resp: await self._check_response(resp) me_response = await resp.json() return MeData(**me_response) async def get_version(self) -> VersionData: """Returns information about the API of the inverter. No login is required. """ async with self._session_request("info/version") as resp: await self._check_response(resp) response = await resp.json() return VersionData(**response) @_relogin async def get_events(self, max_count=10, lang=None) -> Iterable[EventData]: """Returns a list with the latest localized events. :param max_count: the max number of events to read :param lang: the RFC1766 based language code, for example 'de_CH' or 'en' """ if lang is None: lang = locale.getlocale()[0] language = lang[:2].lower() variant = lang[3:5].lower() if language not in ApiClient.SUPPORTED_LANGUAGES.keys(): # Fallback to default language = "en" variant = "gb" else: variants = ApiClient.SUPPORTED_LANGUAGES[language] if variant not in variants: variant = variants[0] request = {"language": f"{language}-{variant}", "max": max_count} async with self._session_request( "events/latest", method="POST", json=request ) as resp: await self._check_response(resp) event_response = await resp.json() return [EventData(**x) for x in event_response] async def get_modules(self) -> Iterable[ModuleData]: """Return list of all available modules (providing process data or settings).""" async with self._session_request("modules") as resp: await self._check_response(resp) modules_response = await resp.json() return [ModuleData(**x) for x in modules_response] @_relogin async def get_process_data(self) -> Mapping[str, Iterable[str]]: """Return a dictionary of all processdata ids and its module ids. :return: a dictionary with the module id as key and a list of process data ids as value """ async with self._session_request("processdata") as resp: await self._check_response(resp) data_response = await resp.json() return {x["moduleid"]: x["processdataids"] for x in data_response} @overload async def get_process_data_values( self, module_id: str, processdata_id: str, ) -> Mapping[str, ProcessDataCollection]: ... @overload async def get_process_data_values( self, module_id: str, processdata_id: Iterable[str], ) -> Mapping[str, ProcessDataCollection]: ... @overload async def get_process_data_values( self, module_id: str, ) -> Mapping[str, ProcessDataCollection]: ... @overload async def get_process_data_values( self, module_id: Mapping[str, Iterable[str]], ) -> Mapping[str, ProcessDataCollection]: ... @overload async def get_process_data_values( self, module_id: Union[str, Mapping[str, Iterable[str]]], processdata_id: Union[str, Iterable[str], None] = None, ) -> Mapping[str, ProcessDataCollection]: ... @_relogin async def get_process_data_values( self, module_id: Union[str, Mapping[str, Iterable[str]]], processdata_id: Union[str, Iterable[str], None] = None, ) -> Mapping[str, ProcessDataCollection]: """Return a dictionary of process data of one or more modules. :param module_id: required, must be a module id or a mapping with the module id as key and the process data ids as values. :param processdata_id: optional, if given `module_id` must be string. Can be either a string or a list of string. If missing all process data ids are returned. :return: a dictionary with the module id as key and a instance of :py:class:`ProcessDataCollection` as value """ if isinstance(module_id, str) and processdata_id is None: # get all process data of a module async with self._session_request(f"processdata/{module_id}") as resp: await self._check_response(resp) data_response = await resp.json() return { data_response[0]["moduleid"]: ProcessDataCollection( process_data_list(data_response[0]["processdata"]) ) } if isinstance(module_id, str) and isinstance(processdata_id, str): # get a single process data of a module async with self._session_request( f"processdata/{module_id}/{processdata_id}" ) as resp: await self._check_response(resp) data_response = await resp.json() return { data_response[0]["moduleid"]: ProcessDataCollection( process_data_list(data_response[0]["processdata"]) ) } if ( isinstance(module_id, str) and processdata_id is not None and hasattr(processdata_id, "__iter__") ): # get multiple process data of a module ids = ",".join(processdata_id) async with self._session_request(f"processdata/{module_id}/{ids}") as resp: await self._check_response(resp) data_response = await resp.json() return { data_response[0]["moduleid"]: ProcessDataCollection( process_data_list(data_response[0]["processdata"]) ) } if isinstance(module_id, dict) and processdata_id is None: # get multiple process data of multiple modules request = [] for mid, pids in module_id.items(): # the json encoder expects that iterables are either list or tuples, # other types has to be converted if isinstance(pids, (list, tuple)): request.append(dict(moduleid=mid, processdataids=pids)) else: request.append(dict(moduleid=mid, processdataids=list(pids))) async with self._session_request( "processdata", method="POST", json=request ) as resp: await self._check_response(resp) data_response = await resp.json() return { x["moduleid"]: ProcessDataCollection( process_data_list(x["processdata"]) ) for x in data_response } raise TypeError("Invalid combination of module_id and processdata_id.") async def get_settings(self) -> Mapping[str, Iterable[SettingsData]]: """Return list of all modules with a list of available settings identifiers.""" async with self._session_request("settings") as resp: await self._check_response(resp) response = await resp.json() result: Dict[str, List[SettingsData]] = {} for module in response: mid = module["moduleid"] data = [SettingsData(**x) for x in module["settings"]] result[mid] = data return result @overload async def get_setting_values( self, module_id: str, setting_id: str, ) -> Mapping[str, Mapping[str, str]]: ... @overload async def get_setting_values( self, module_id: str, setting_id: Iterable[str], ) -> Mapping[str, Mapping[str, str]]: ... @overload async def get_setting_values( self, module_id: str, ) -> Mapping[str, Mapping[str, str]]: ... @overload async def get_setting_values( self, module_id: Mapping[str, Iterable[str]], ) -> Mapping[str, Mapping[str, str]]: ... @_relogin async def get_setting_values( self, module_id: Union[str, Mapping[str, Iterable[str]]], setting_id: Union[str, Iterable[str], None] = None, ) -> Mapping[str, Mapping[str, str]]: """Return a dictionary of setting values of one or more modules. :param module_id: required, must be a module id or a dictionary with the module id as key and the setting ids as values. :param setting_id: optional, if given `module_id` must be string. Can be either a string or a list of string. If missing all setting ids are returned. """ if isinstance(module_id, str) and setting_id is None: # get all setting data of a module async with self._session_request(f"settings/{module_id}") as resp: await self._check_response(resp) data_response = await resp.json() return {module_id: {data_response[0]["id"]: data_response[0]["value"]}} if isinstance(module_id, str) and isinstance(setting_id, str): # get a single setting of a module async with self._session_request( f"settings/{module_id}/{setting_id}" ) as resp: await self._check_response(resp) data_response = await resp.json() return {module_id: {data_response[0]["id"]: data_response[0]["value"]}} if ( isinstance(module_id, str) and setting_id is not None and hasattr(setting_id, "__iter__") ): # get multiple settings of a module ids = ",".join(setting_id) async with self._session_request(f"settings/{module_id}/{ids}") as resp: await self._check_response(resp) data_response = await resp.json() return {module_id: {x["id"]: x["value"] for x in data_response}} if isinstance(module_id, dict) and setting_id is None: # get multiple process data of multiple modules request = [] for mid, pids in module_id.items(): # the json encoder expects that iterables are either list or tuples, # other types has to be converted if isinstance(pids, (list, tuple)): request.append(dict(moduleid=mid, settingids=pids)) else: request.append(dict(moduleid=mid, settingids=list(pids))) async with self._session_request( "settings", method="POST", json=request ) as resp: await self._check_response(resp) data_response = await resp.json() return { x["moduleid"]: {y["id"]: y["value"] for y in x["settings"]} for x in data_response } raise TypeError("Invalid combination of module_id and setting_id.") @_relogin async def set_setting_values(self, module_id: str, values: Mapping[str, str]): """Write a list of settings for one modules.""" request = [ { "moduleid": module_id, "settings": [dict(value=v, id=k) for k, v in values.items()], } ] async with self._session_request( "settings", method="PUT", json=request ) as resp: await self._check_response(resp) @_relogin async def download_logdata( self, writer: IO, begin: Union[datetime, None] = None, end: Union[datetime, None] = None, ): """Download logdata as tab-separated file.""" request = {} if begin is not None: request["begin"] = begin.strftime("%Y-%m-%d") if end is not None: request["end"] = end.strftime("%Y-%m-%d") async with self._session_request( "logdata/download", method="POST", json=request, timeout=ClientTimeout(total=360), ) as resp: await self._check_response(resp) async for data in resp.content.iter_any(): writer.write(data.decode("UTF-8")) stegm-pykoplenti-6537eba/pykoplenti/cli.py000066400000000000000000000412221477302610500207700ustar00rootroot00000000000000from ast import literal_eval import asyncio from collections import defaultdict from dataclasses import dataclass from inspect import iscoroutinefunction import os from pathlib import Path from pprint import pprint import re import tempfile import traceback from typing import Any, Awaitable, Callable, Dict, Optional, Union import warnings from aiohttp import ClientSession, ClientTimeout import click from prompt_toolkit import PromptSession, print_formatted_text from pykoplenti import ApiClient from pykoplenti.extended import ExtendedApiClient class SessionCache: """Persistent the session in a temporary file.""" def __init__(self, host: str, user: str): self._cache_file = Path( tempfile.gettempdir(), f"pykoplenti-session-{host}-{user}" ) def read_session_id(self) -> Union[str, None]: if self._cache_file.is_file(): with self._cache_file.open("rt") as f: return f.readline(256) else: return None def write_session_id(self, id: str): f = os.open(self._cache_file, os.O_WRONLY | os.O_TRUNC | os.O_CREAT, mode=0o600) try: os.write(f, id.encode("ascii")) finally: os.close(f) def remove(self): self._cache_file.unlink(missing_ok=True) class ApiShell: """Provides a shell-like access to the inverter.""" def __init__(self, client: ApiClient, user: str): super().__init__() self.client = client self._session_cache = SessionCache(self.client.host, user) async def prepare_client(self, key: Optional[str], service_code: Optional[str]): # first try to reuse existing session session_id = self._session_cache.read_session_id() if session_id is not None: self.client.session_id = session_id print_formatted_text("Trying to reuse existing session... ", end="") me = await self.client.get_me() if me.is_authenticated: print_formatted_text("Success") return print_formatted_text("Failed") if key is not None: print_formatted_text("Logging in... ", end="") await self.client.login(key=key, service_code=service_code) if self.client.session_id is not None: self._session_cache.write_session_id(self.client.session_id) print_formatted_text("Success") else: print_formatted_text("Session could not be reused and no key given") def print_exception(self): """Prints an excpetion from executing a method.""" print_formatted_text(traceback.format_exc()) async def run(self, key: Optional[str], service_code: Optional[str]): session = PromptSession[str]() print_formatted_text(flush=True) # Initialize output # Test commands: # get_settings # get_setting_values 'devices:local' 'Battery:MinSoc' # get_setting_values 'devices:local' ['Battery:MinSoc', \ # 'Battery:MinHomeComsumption'] # get_setting_values 'scb:time' # set_setting_values 'devices:local' {'Battery:MinSoc':'15'} await self.prepare_client(key, service_code) while True: try: text = await session.prompt_async("(pykoplenti)> ") if text.strip().lower() == "exit": raise EOFError() if text.strip() == "": continue else: # TODO split does not know about lists or dicts or strings # with spaces method_name, *arg_values = text.strip().split() if method_name == "help": self._do_help(arg_values) continue method = self._get_method(method_name) if method is None: continue args = self._create_args(arg_values) if args is None: continue await self._execute(method, args) except KeyboardInterrupt: continue except EOFError: break def _do_help(self, argv): if len(argv) == 0: print_formatted_text("Try: help ") else: method = getattr(self.client, argv[0]) print_formatted_text(method.__doc__) def _get_method(self, name): try: return getattr(self.client, name) except AttributeError: print_formatted_text(f"Unknown method: {name}") return None def _create_args(self, argv): try: return [literal_eval(x) for x in argv] except Exception: print_formatted_text("Error parsing arguments") self.print_exception() return None async def _execute(self, method, args): try: if iscoroutinefunction(method): result = await method(*args) else: result = method(*args) pprint(result) except Exception: print_formatted_text("Error executing method") self.print_exception() async def repl_main( host: str, port: int, key: Optional[str], service_code: Optional[str] ): async with ClientSession(timeout=ClientTimeout(total=10)) as session: client = ExtendedApiClient(session, host=host, port=port) shell = ApiShell(client, "user" if service_code is None else "master") await shell.run(key, service_code) async def command_main( host: str, port: int, key: Optional[str], service_code: Optional[str], fn: Callable[[ApiClient], Awaitable[Any]], ): async with ClientSession(timeout=ClientTimeout(total=10)) as session: client = ExtendedApiClient(session, host=host, port=port) session_cache = SessionCache(host, "user" if service_code is None else "master") # Try to reuse an existing session client.session_id = session_cache.read_session_id() me = await client.get_me() if not me.is_authenticated: if key is None: raise ValueError("Could not reuse session and no login key is given.") # create a new session await client.login(key=key, service_code=service_code) if client.session_id is not None: session_cache.write_session_id(client.session_id) await fn(client) @dataclass class GlobalArgs: """Global arguments over all sub commands.""" host: str = "" """The hostname or ip of the inverter.""" port: int = 0 """The port on which the API listens on the inverter.""" key: Optional[str] = None """The key (password or master key) to login into the API. If None, a previous session cache is used. If the session cache has no valid session, no login is executed. """ service_code: Optional[str] = None """The service code for master access. Only necessary for master access. If missing, user acess is used. """ pass_global_args = click.make_pass_decorator(GlobalArgs, ensure=True) def _parse_credentials_file(path: Path) -> tuple[Optional[str], Optional[str]]: """Parse credentials file returning (key, service_code)""" key = service_code = None for line in path.read_text().splitlines(): if "=" not in line: return line.strip(), None name, _, value = line.partition("=") name = name.strip() if name in ("password", "key", "master-key"): key = value.strip() elif name == "service-code": service_code = value.strip() return key, service_code @click.group() @click.option("--host", help="Hostname or IP of the inverter") @click.option("--port", default=80, help="Port of the inverter", show_default=True) @click.option( "--password", default=None, help="Password or master key (also device id)" ) @click.option("--service-code", default=None, help="service code for installer access") @click.option( "--password-file", default="secrets", help="Path to password file - deprecated, use --credentials", show_default=True, type=click.Path(exists=False, dir_okay=False, readable=True, path_type=Path), ) @click.option( "--credentials", default=None, help="Path to the credentials file. This has a simple ini-format without sections. " "For user access, use the 'password'. For installer access, use the 'master-key' " "and 'service-key'.", type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path), ) @pass_global_args def cli( global_args: GlobalArgs, host: str, port: int, password: Optional[str], service_code: Optional[str], password_file: Path, credentials: Path, ): """Handling of global arguments with click""" global_args.host = host global_args.port = port if password is not None: global_args.key = password elif password_file.is_file(): with password_file.open("rt") as f: global_args.key = f.readline() warnings.warn( "--password-file is deprecated. Use --credentials instead.", DeprecationWarning, ) if service_code is not None: global_args.service_code = service_code if credentials is not None: if password is not None: raise click.BadOptionUsage( "password", "password cannot be used with credentials" ) if password_file is not None and password_file.is_file(): raise click.BadOptionUsage( "password-file", "password-file cannot be used with credentials" ) if service_code is not None: raise click.BadOptionUsage( "service_code", "service_code cannot be used with credentials" ) global_args.key, global_args.service_code = _parse_credentials_file(credentials) @cli.command() @pass_global_args def repl(global_args: GlobalArgs): """Provides a simple REPL for executing API requests to the inverter.""" asyncio.run( repl_main( global_args.host, global_args.port, global_args.key, global_args.service_code, ) ) @cli.command() @click.option("--lang", default=None, help="language for events") @click.option("--count", default=10, help="number of events to read") @pass_global_args def read_events(global_args: GlobalArgs, lang, count): """Returns the last events""" async def fn(client: ApiClient): data = await client.get_events(lang=lang, max_count=count) for event in data: print( f"{event.is_active < 5} {event.start_time} {event.end_time} " f"{event.description}" ) asyncio.run( command_main( global_args.host, global_args.port, global_args.key, global_args.service_code, fn, ) ) @cli.command() @click.option( "--out", required=True, type=click.File(mode="wt", encoding="UTF-8"), help="file to write the log data to", ) @click.option("--begin", type=click.DateTime(["%Y-%m-%d"]), help="first day to export") @click.option("--end", type=click.DateTime(["%Y-%m-%d"]), help="last day to export") @pass_global_args def download_log(global_args: GlobalArgs, out, begin, end): """Download the log data from the inverter to a file.""" async def fn(client: ApiClient): await client.download_logdata(writer=out, begin=begin, end=end) asyncio.run( command_main( global_args.host, global_args.port, global_args.key, global_args.service_code, fn, ) ) @cli.command() @pass_global_args def all_processdata(global_args: GlobalArgs): """Returns a list of all available process data.""" async def fn(client: ApiClient): data = await client.get_process_data() for k, v in data.items(): for x in v: print(f"{k}/{x}") asyncio.run( command_main( global_args.host, global_args.port, global_args.key, global_args.service_code, fn, ) ) @cli.command() @click.argument("ids", required=True, nargs=-1) @pass_global_args def read_processdata(global_args: GlobalArgs, ids): """Returns the values of the given process data. IDS is the identifier (/) of one or more processdata to read. \b Examples: read-processdata devices:local/Inverter:State """ async def fn(client: ApiClient): if len(ids) == 1 and "/" not in ids[0]: # all process data ids of a moudle values = await client.get_process_data_values(ids[0]) else: query = defaultdict(list) for id in ids: m = re.match(r"(?P.+)/(?P.+)", id) if not m: raise Exception(f"Invalid format of {id}") module_id = m.group("module_id") setting_id = m.group("processdata_id") query[module_id].append(setting_id) values = await client.get_process_data_values(query) for k, v in values.items(): for x in v.values(): print(f"{k}/{x.id}={x.value}") asyncio.run( command_main( global_args.host, global_args.port, global_args.key, global_args.service_code, fn, ) ) @cli.command() @click.option( "--rw", is_flag=True, default=False, help="display only writable settings" ) @pass_global_args def all_settings(global_args: GlobalArgs, rw: bool): """Returns the ids of all settings.""" async def fn(client: ApiClient): settings = await client.get_settings() for k, v in settings.items(): for x in v: if not rw or x.access == "readwrite": print(f"{k}/{x.id}") asyncio.run( command_main( global_args.host, global_args.port, global_args.key, global_args.service_code, fn, ) ) @cli.command() @click.argument("ids", required=True, nargs=-1) @pass_global_args def read_settings(global_args: GlobalArgs, ids): """Read the value of the given settings. IDS is the identifier (/) of one or more settings to read \b Examples: read-settings devices:local/Battery:MinSoc read-settings devices:local/Battery:MinSoc \ devices:local/Battery:MinHomeComsumption """ async def fn(client: ApiClient): query = defaultdict(list) for id in ids: m = re.match(r"(?P.+)/(?P.+)", id) if not m: raise Exception(f"Invalid format of {id}") module_id = m.group("module_id") setting_id = m.group("setting_id") query[module_id].append(setting_id) values = await client.get_setting_values(query) for k, x in values.items(): for i, v in x.items(): print(f"{k}/{i}={v}") asyncio.run( command_main( global_args.host, global_args.port, global_args.key, global_args.service_code, fn, ) ) @cli.command() @click.argument("id_values", required=True, nargs=-1) @pass_global_args def write_settings(global_args: GlobalArgs, id_values): """Write the values of the given settings. ID_VALUES is the identifier plus the the value to write \b Examples: write-settings devices:local/Battery:MinSoc=15 """ async def fn(client: ApiClient): query: Dict[str, Dict[str, str]] = defaultdict(dict) for id_value in id_values: m = re.match( r"(?P.+)/(?P.+)=(?P.+)", id_value ) if not m: raise Exception(f"Invalid format of {id_value}") module_id = m.group("module_id") setting_id = m.group("setting_id") value = m.group("value") query[module_id][setting_id] = value for module_id, setting_values in query.items(): await client.set_setting_values(module_id, setting_values) asyncio.run( command_main( global_args.host, global_args.port, global_args.key, global_args.service_code, fn, ) ) # entry point for pycharm; should not be used for commandline usage if __name__ == "__main__": import sys cli(sys.argv[1:], auto_envvar_prefix="PYKOPLENTI") stegm-pykoplenti-6537eba/pykoplenti/extended.py000066400000000000000000000221371477302610500220250ustar00rootroot00000000000000"""Extended ApiClient which provides virtual process data values.""" from abc import ABC, abstractmethod from collections import ChainMap, defaultdict from typing import Final, Iterable, Literal, Mapping, MutableMapping, Union from aiohttp import ClientSession from .api import ApiClient from .model import ProcessData, ProcessDataCollection _VIRT_MODUL_ID: Final = "_virt_" class _VirtProcessDataItemBase(ABC): """Base class for all virtual process data items.""" def __init__(self, processid: str, process_data: dict[str, set[str]]) -> None: self.processid = processid self.process_data = process_data self.available_process_data: dict[str, set[str]] = {} def update_actual_process_ids( self, available_process_ids: Mapping[str, Iterable[str]] ): """Update which process data for this item are available.""" self.available_process_data.clear() for module_id, process_ids in self.process_data.items(): if module_id in available_process_ids: matching_process_ids = process_ids.intersection( available_process_ids[module_id] ) if len(matching_process_ids) > 0: self.available_process_data[module_id] = matching_process_ids @abstractmethod def get_value( self, process_values: Mapping[str, ProcessDataCollection] ) -> ProcessData: ... @abstractmethod def is_available(self) -> bool: ... class _VirtProcessDataItemSum(_VirtProcessDataItemBase): def get_value( self, process_values: Mapping[str, ProcessDataCollection] ) -> ProcessData: values: list[float] = [] for module_id, process_ids in self.available_process_data.items(): values += (process_values[module_id][pid].value for pid in process_ids) return ProcessData(id=self.processid, unit="W", value=sum(values)) def is_available(self) -> bool: return len(self.available_process_data) > 0 class _VirtProcessDataItemEnergyToGrid(_VirtProcessDataItemBase): def __init__( self, processid: str, scope: Literal["Total", "Year", "Month", "Day"] ) -> None: super().__init__( processid, { "scb:statistic:EnergyFlow": { f"Statistic:Yield:{scope}", f"Statistic:EnergyHomeBat:{scope}", f"Statistic:EnergyHomePv:{scope}", } }, ) self.scope = scope def get_value( self, process_values: Mapping[str, ProcessDataCollection] ) -> ProcessData: statistics = process_values["scb:statistic:EnergyFlow"] energy_yield = statistics[f"Statistic:Yield:{self.scope}"].value energy_home_bat = statistics[f"Statistic:EnergyHomeBat:{self.scope}"].value energy_home_pv = statistics[f"Statistic:EnergyHomePv:{self.scope}"].value return ProcessData( id=self.processid, unit="Wh", value=energy_yield - energy_home_pv - energy_home_bat, ) def is_available(self) -> bool: return self.available_process_data == self.process_data class _VirtProcessDataManager: """Manager for all virtual process data items.""" def __init__(self) -> None: self._virt_process_data: Iterable[_VirtProcessDataItemBase] = [ _VirtProcessDataItemSum( "pv_P", { "devices:local:pv1": {"P"}, "devices:local:pv2": {"P"}, "devices:local:pv3": {"P"}, }, ), _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Total", "Total"), _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Year", "Year"), _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Month", "Month"), _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Day", "Day"), ] def initialize(self, available_process_data: Mapping[str, Iterable[str]]): """Initialize the virtual items with the list of available process ids.""" for vpd in self._virt_process_data: vpd.update_actual_process_ids(available_process_data) def adapt_process_data_response( self, process_data: dict[str, list[str]] ) -> Mapping[str, list[str]]: """Adapt the reponse of reading process data.""" virt_process_data: dict[str, list[str]] = {_VIRT_MODUL_ID: []} for vpd in self._virt_process_data: if vpd.is_available(): virt_process_data[_VIRT_MODUL_ID].append(vpd.processid) return ChainMap(process_data, virt_process_data) def adapt_process_value_request( self, process_data: Mapping[str, Iterable[str]] ) -> Mapping[str, Iterable[str]]: """Adapt the request for process values.""" result: MutableMapping[str, set[str]] = defaultdict(set) for mid, pids in process_data.items(): result[mid].update(pids) for requested_virtual_process_id in result.pop(_VIRT_MODUL_ID): for virtual_process_data in self._virt_process_data: if virtual_process_data.is_available(): if requested_virtual_process_id == virtual_process_data.processid: # add ids for virtual if they are missing for ( mid, pids, ) in virtual_process_data.available_process_data.items(): result[mid].update(pids) break else: raise ValueError( f"No virtual process data '{requested_virtual_process_id}'." ) return result def adapt_process_value_response( self, values: Mapping[str, ProcessDataCollection], request_data: Mapping[str, Iterable[str]], ) -> Mapping[str, ProcessDataCollection]: """Adapt the reponse for process values.""" result = {} # add virtual items virtual_process_data_values = [] for id in request_data[_VIRT_MODUL_ID]: for vpd in self._virt_process_data: if vpd.processid == id: virtual_process_data_values.append(vpd.get_value(values)) result["_virt_"] = ProcessDataCollection(virtual_process_data_values) # add all values which was requested but not the extra ids for the virtual ids for mid, pdc in values.items(): if mid in request_data: pids = [x for x in pdc.values() if x.id in request_data[mid]] if len(pids) > 0: result[mid] = ProcessDataCollection(pids) return result class ExtendedApiClient(ApiClient): """Extend ApiClient with virtual process data.""" def __init__(self, websession: ClientSession, host: str, port: int = 80): super().__init__(websession, host, port) self._virt_process_data = _VirtProcessDataManager() self._virt_process_data_initialized = False async def get_process_data(self) -> Mapping[str, Iterable[str]]: process_data = await super().get_process_data() self._virt_process_data.initialize(process_data) self._virt_process_data_initialized = True return self._virt_process_data.adapt_process_data_response(process_data) async def get_process_data_values( self, module_id: Union[str, Mapping[str, Iterable[str]]], processdata_id: Union[str, Iterable[str], None] = None, ) -> Mapping[str, ProcessDataCollection]: contains_virt_process_data = ( isinstance(module_id, str) and _VIRT_MODUL_ID == module_id ) or (isinstance(module_id, dict) and _VIRT_MODUL_ID in module_id) if not contains_virt_process_data: # short-cut if no virtual process is requested return await super().get_process_data_values(module_id, processdata_id) process_data: dict[str, Iterable[str]] = {} if isinstance(module_id, str) and processdata_id is None: process_data[module_id] = [] elif isinstance(module_id, str) and isinstance(processdata_id, str): process_data[module_id] = [processdata_id] elif ( isinstance(module_id, str) and processdata_id is not None and hasattr(processdata_id, "__iter__") ): process_data[module_id] = list(processdata_id) elif isinstance(module_id, Mapping) and processdata_id is None: process_data.update(module_id) else: raise TypeError("Invalid combination of module_id and processdata_id.") if not self._virt_process_data_initialized: pd = await self.get_process_data() self._virt_process_data.initialize(pd) self._virt_process_data_initialized = True process_values = await super().get_process_data_values( self._virt_process_data.adapt_process_value_request(process_data) ) return self._virt_process_data.adapt_process_value_response( process_values, process_data ) stegm-pykoplenti-6537eba/pykoplenti/model.py000066400000000000000000000057101477302610500213230ustar00rootroot00000000000000from datetime import datetime from typing import Final, Iterator, Mapping, Optional import pydantic from pydantic import BaseModel, Field class MeData(BaseModel): """Represent the data of the 'me'-request.""" is_locked: bool = Field(alias="locked") is_active: bool = Field(alias="active") is_authenticated: bool = Field(alias="authenticated") permissions: list[str] = Field() is_anonymous: bool = Field(alias="anonymous") role: str class VersionData(BaseModel): """Represent the data of the 'version'-request.""" api_version: str hostname: str name: str sw_version: str class ModuleData(BaseModel): """Represents a single module.""" id: str type: str class ProcessData(BaseModel): """Represents a single process data.""" id: str unit: str value: float class ProcessDataCollection(Mapping): """Represents a collection of process data value.""" def __init__(self, process_data: list[ProcessData]): self._process_data = process_data def __len__(self) -> int: return len(self._process_data) def __iter__(self) -> Iterator[str]: return (x.id for x in self._process_data) def __getitem__(self, item) -> ProcessData: try: return next(x for x in self._process_data if x.id == item) except StopIteration: raise KeyError(item) from None def __eq__(self, __other: object) -> bool: if not isinstance(__other, ProcessDataCollection): return False return self._process_data == __other._process_data def __str__(self): return "[" + ",".join(str(x) for x in self._process_data) + "]" def __repr__(self): return ( "ProcessDataCollection([" + ",".join(repr(x) for x in self._process_data) + "])" ) class SettingsData(BaseModel): """Represents a single settings data.""" min: Optional[str] max: Optional[str] default: Optional[str] access: str unit: Optional[str] id: str type: str class EventData(BaseModel): """Represents an event of the inverter.""" start_time: datetime end_time: datetime code: int long_description: str category: str description: str group: str is_active: bool # pydantic version specific code # In pydantic 2.x `parse_obj_as` is no longer supported. To stay compatible to # both version a small wrapper function is used. if pydantic.VERSION.startswith("2."): from pydantic import TypeAdapter _process_list_adapter: Final = TypeAdapter(list[ProcessData]) def process_data_list(json) -> list[ProcessData]: """Process json as a list of ProcessData objects.""" return _process_list_adapter.validate_python(json) else: from pydantic import parse_obj_as def process_data_list(json) -> list[ProcessData]: """Process json as a list of ProcessData objects.""" return parse_obj_as(list[ProcessData], json) stegm-pykoplenti-6537eba/pykoplenti/py.typed000066400000000000000000000000001477302610500213330ustar00rootroot00000000000000stegm-pykoplenti-6537eba/pyproject.toml000066400000000000000000000005111477302610500203610ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.black] target-version = ["py310"] [tool.isort] profile = "black" force_sort_within_sections = true known_first_party = [ "kostal", "tests", ] forced_separate = [ "tests", ] combine_as_imports = true [tool.ruff] line-length = 88stegm-pykoplenti-6537eba/setup.cfg000066400000000000000000000023511477302610500172720ustar00rootroot00000000000000[metadata] name = pykoplenti version = 1.4.0 description = Python REST-Client for Kostal Plenticore Solar Inverters long_description = file: README.md long_description_content_type = text/markdown keywords = rest kostal plenticore solar author = @stegm url = https://github.com/stegm/pyclient_koplenti project_urls = repository = https://github.com/stegm/pyclient_koplenti changelog = https://github.com/stegm/pykoplenti/blob/master/CHANGELOG.md issues = https://github.com/stegm/pykoplenti/issues classifiers = Development Status :: 4 - Beta Environment :: Console Intended Audience :: Developers License :: OSI Approved :: Apache Software License Programming Language :: Python :: 3 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Topic :: Software Development :: Libraries [options] packages = pykoplenti install_requires = aiohttp ~= 3.8 pycryptodome ~= 3.19 pydantic >= 1.10 [options.package_data] pykoplenti = py.typed [options.extras_require] CLI = prompt_toolkit >= 3.0 click >= 8.0 [options.entry_points] console_scripts = pykoplenti = pykoplenti.cli:cli [CLI] stegm-pykoplenti-6537eba/setup.py000066400000000000000000000000461477302610500171620ustar00rootroot00000000000000import setuptools setuptools.setup() stegm-pykoplenti-6537eba/tests/000077500000000000000000000000001477302610500166125ustar00rootroot00000000000000stegm-pykoplenti-6537eba/tests/conftest.py000066400000000000000000000044661477302610500210230ustar00rootroot00000000000000import os from typing import Any, Callable, Union from unittest.mock import AsyncMock, MagicMock from aiohttp import ClientResponse, ClientSession import pytest import pykoplenti only_smoketest: pytest.MarkDecorator = pytest.mark.skipif( os.getenv("SMOKETEST_HOST") is None, reason="Smoketest must be explicitly executed" ) @pytest.fixture def websession_responses() -> list[MagicMock]: """Provides a mutable list for responses of a ClientSession.""" return [] @pytest.fixture def websession(websession_responses) -> MagicMock: """Creates a mocked ClientSession. The client_response_factoryfixture can be used to add responses. """ websession = MagicMock(spec_set=ClientSession, name="websession Mock") websession.request.return_value.__aenter__.side_effect = websession_responses return websession @pytest.fixture def client_response_factory( websession_responses, ) -> Callable[[int, Any], MagicMock]: """Provides a factory to add responses to a ClientSession.""" def factory(status: int = 200, json: Union[list[Any], dict[Any, Any], None] = None): response = MagicMock(spec_set=ClientResponse, name="request Mock") response.status = status if json is not None: response.json.return_value = json websession_responses.append(response) return response return factory @pytest.fixture def pykoplenti_client(websession) -> pykoplenti.ApiClient: """Returns a pykoplenti API-Client. The _login method is replaced with an AsyncMock. """ client = pykoplenti.ApiClient(websession, "localhost") login_mock = AsyncMock() client._login = login_mock # type: ignore return client @pytest.fixture def pykoplenti_extended_client(websession) -> pykoplenti.ExtendedApiClient: """Returns a pykoplenti Extended API-Client. The _login method is replaced with an AsyncMock. """ client = pykoplenti.ExtendedApiClient(websession, "localhost") login_mock = AsyncMock() client._login = login_mock # type: ignore return client @pytest.fixture def smoketest_config() -> tuple[str, int, str]: """Return the configuration for smoke tests.""" return ( os.getenv("SMOKETEST_HOST", "localhost"), int(os.getenv("SMOKETEST_PORT", 80)), os.getenv("SMOKETEST_PASS", ""), ) stegm-pykoplenti-6537eba/tests/test_cli.py000066400000000000000000000103001477302610500207640ustar00rootroot00000000000000from pathlib import Path from click.testing import CliRunner import pytest from pykoplenti.cli import cli, SessionCache import os from conftest import only_smoketest @pytest.fixture def credentials(tmp_path: Path, smoketest_config: tuple[str, int, str]): _, _, password = smoketest_config credentials_path = tmp_path / "credentials" credentials_path.write_text(f"password={password}") return credentials_path @pytest.fixture def dummy_credentials(tmp_path: Path): credentials_path = tmp_path / "credentials" credentials_path.write_text("password=dummy") return credentials_path @pytest.fixture def session_cache(smoketest_config: tuple[str, int, str]): host, _, _ = smoketest_config session_cache = SessionCache(host, "user") session_cache.remove() yield session_cache session_cache.remove() class TestInvalidGlobalOptions: """Test invalid global options.""" def test_crendentials_and_password(self, dummy_credentials: Path): runner = CliRunner() result = runner.invoke( cli, [ "--credentials", str(dummy_credentials), "--password", "topsecret", "all-processdata", ], ) assert result.exit_code == 2 assert "password cannot be used with credentials" in result.output @pytest.mark.filterwarnings( "ignore:--password-file is deprecated. Use --credentials instead." ) def test_crendentials_and_password_file(self, dummy_credentials: Path): runner = CliRunner() result = runner.invoke( cli, [ "--credentials", str(dummy_credentials), "--password-file", str(dummy_credentials), "all-processdata", ], ) assert result.exit_code == 2 assert "password-file cannot be used with credentials" in result.output def test_crendentials_and_service_code( self, dummy_credentials: Path, tmp_path: Path ): # As --password-file has a default value, this ensures # that no default password-file exists. os.chdir(tmp_path) runner = CliRunner() result = runner.invoke( cli, [ "--credentials", str(dummy_credentials), "--service-code", "topsecret", "all-processdata", ], ) assert result.exit_code == 2 assert "service_code cannot be used with credentials" in result.output @only_smoketest def test_read_process_data( credentials: Path, session_cache: SessionCache, smoketest_config: tuple[str, int, str], ): # As --password-file has a default value, this ensures # that no default password-file exists. os.chdir(credentials.parent) host, port, _ = smoketest_config runner = CliRunner() result = runner.invoke( cli, [ "--host", host, "--port", str(port), "--credentials", str(credentials), "all-processdata", ], ) assert result.exit_code == 0 # check any data which is most likely present on most inverter assert "devices:local/Inverter:State" in result.stdout.splitlines() assert session_cache.read_session_id() is not None @only_smoketest def test_read_settings_data( credentials: Path, session_cache: SessionCache, smoketest_config: tuple[str, int, str], ): # As --password-file has a default value, this ensures # that no default password-file exists. os.chdir(credentials.parent) host, port, _ = smoketest_config runner = CliRunner() result = runner.invoke( cli, [ "--host", host, "--port", str(port), "--credentials", str(credentials), "all-settings", ], ) assert result.exit_code == 0 # check any data which is most likely present on most inverter assert "devices:local/Branding:ProductName1" in result.stdout.splitlines() assert session_cache.read_session_id() is not None stegm-pykoplenti-6537eba/tests/test_extendedapiclient.py000066400000000000000000000412211477302610500237140ustar00rootroot00000000000000from typing import Any, Callable, Iterable, Union from unittest.mock import ANY, MagicMock, call import pytest import pykoplenti class _IterableMatcher: """Matcher for iterable which does not check the order.""" def __init__(self, expected: Iterable): self._expected = list(expected) def __eq__(self, other: object) -> bool: if not hasattr(other, "__iter__"): return False # check if every item in expected matched an item in other expected = self._expected.copy() for item in other: if (idx := expected.index(item)) >= 0: del expected[idx] else: return False return len(expected) == 0 def __str__(self) -> str: return str(self._expected) def __repr__(self) -> str: return repr(self._expected) class TestVirtualProcessDataValuesDcSum: """This class contains tests for virtual process data values for DC sum.""" @pytest.mark.asyncio async def test_virtual_process_data( self, pykoplenti_extended_client: pykoplenti.ExtendedApiClient, client_response_factory: Callable[ [int, Union[list[Any], dict[Any, Any]]], MagicMock ], websession: MagicMock, ): """Test virtual process data for PV power if depencies are present.""" client_response_factory( 200, [ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, ], ) values = await pykoplenti_extended_client.get_process_data() websession.request.assert_called_once_with( "GET", ANY, headers=ANY, ) assert values == { "_virt_": ["pv_P"], "devices:local:pv1": ["P"], "devices:local:pv2": ["P"], } @pytest.mark.asyncio async def test_virtual_process_data_value( self, pykoplenti_extended_client: pykoplenti.ExtendedApiClient, client_response_factory: Callable[ [int, Union[list[Any], dict[Any, Any]]], MagicMock ], websession: MagicMock, ): """Test virtual process data for PV power.""" client_response_factory( 200, [ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, {"moduleid": "devices:local:pv3", "processdataids": ["P"]}, ], ) client_response_factory( 200, [ { "moduleid": "devices:local:pv1", "processdata": [ {"id": "P", "unit": "W", "value": 700.0}, ], }, { "moduleid": "devices:local:pv2", "processdata": [ {"id": "P", "unit": "W", "value": 300.0}, ], }, { "moduleid": "devices:local:pv3", "processdata": [ {"id": "P", "unit": "W", "value": 500.0}, ], }, ], ) values = await pykoplenti_extended_client.get_process_data_values( "_virt_", "pv_P" ) websession.request.assert_has_calls( [ call("GET", ANY, headers=ANY), call( "POST", ANY, headers=ANY, json=[ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, {"moduleid": "devices:local:pv3", "processdataids": ["P"]}, ], ), ], any_order=True, ) assert values == { "_virt_": pykoplenti.ProcessDataCollection( [pykoplenti.ProcessData(id="pv_P", unit="W", value=1500.0)] ) } class TestVirtualProcessDataValuesEnergyToGrid: """This class contains tests for virtual process data values for energy to grid.""" @pytest.mark.parametrize("scope", ["Total", "Year", "Month", "Day"]) @pytest.mark.asyncio async def test_virtual_process_data( self, pykoplenti_extended_client: pykoplenti.ExtendedApiClient, client_response_factory: Callable[ [int, Union[list[Any], dict[Any, Any]]], MagicMock ], websession: MagicMock, scope: str, ): """Test virtual process data.""" client_response_factory( 200, [ { "moduleid": "scb:statistic:EnergyFlow", "processdataids": [ f"Statistic:Yield:{scope}", f"Statistic:EnergyHomeBat:{scope}", f"Statistic:EnergyHomePv:{scope}", ], }, ], ) values = await pykoplenti_extended_client.get_process_data() websession.request.assert_called_once_with( "GET", ANY, headers=ANY, ) assert values == { "_virt_": [f"Statistic:EnergyGrid:{scope}"], "scb:statistic:EnergyFlow": [ f"Statistic:Yield:{scope}", f"Statistic:EnergyHomeBat:{scope}", f"Statistic:EnergyHomePv:{scope}", ], } @pytest.mark.parametrize("scope", ["Total", "Year", "Month", "Day"]) @pytest.mark.asyncio async def test_virtual_process_data_value( self, pykoplenti_extended_client: pykoplenti.ExtendedApiClient, client_response_factory: Callable[ [int, Union[list[Any], dict[Any, Any]]], MagicMock ], websession: MagicMock, scope: str, ): """Test virtuel process data for energy to grid.""" client_response_factory( 200, [ { "moduleid": "scb:statistic:EnergyFlow", "processdataids": [ f"Statistic:Yield:{scope}", f"Statistic:EnergyHomeBat:{scope}", f"Statistic:EnergyHomePv:{scope}", ], }, ], ) client_response_factory( 200, [ { "moduleid": "scb:statistic:EnergyFlow", "processdata": [ { "id": f"Statistic:Yield:{scope}", "unit": "Wh", "value": 1000.0, }, { "id": f"Statistic:EnergyHomeBat:{scope}", "unit": "Wh", "value": 100.0, }, { "id": f"Statistic:EnergyHomePv:{scope}", "unit": "Wh", "value": 200.0, }, ], }, ], ) values = await pykoplenti_extended_client.get_process_data_values( "_virt_", f"Statistic:EnergyGrid:{scope}" ) websession.request.assert_has_calls( [ call("GET", ANY, headers=ANY), call( "POST", ANY, headers=ANY, json=[ { "moduleid": "scb:statistic:EnergyFlow", "processdataids": _IterableMatcher( { f"Statistic:Yield:{scope}", f"Statistic:EnergyHomeBat:{scope}", f"Statistic:EnergyHomePv:{scope}", } ), }, ], ), ], any_order=True, ) assert values == { "_virt_": pykoplenti.ProcessDataCollection( [ pykoplenti.ProcessData( id=f"Statistic:EnergyGrid:{scope}", unit="Wh", value=700.0 ) ] ) } @pytest.mark.asyncio async def test_virtual_process_data_no_dc_sum( pykoplenti_extended_client: pykoplenti.ExtendedApiClient, client_response_factory: Callable[ [int, Union[list[Any], dict[Any, Any]]], MagicMock ], websession: MagicMock, ): """Test if no virtual process data is present if dependencies are missing.""" client_response_factory( 200, [ {"moduleid": "devices:local", "processdataids": ["EM_State"]}, ], ) values = await pykoplenti_extended_client.get_process_data() websession.request.assert_called_once_with( "GET", ANY, headers=ANY, ) assert values == { "_virt_": [], "devices:local": ["EM_State"], } @pytest.mark.asyncio async def test_virtual_process_data_and_normal_process_data( pykoplenti_extended_client: pykoplenti.ExtendedApiClient, client_response_factory: Callable[ [int, Union[list[Any], dict[Any, Any]]], MagicMock ], websession: MagicMock, ): """Test if virtual and non-virtual process values can be requested.""" client_response_factory( 200, [ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, ], ) client_response_factory( 200, [ { "moduleid": "devices:local:pv1", "processdata": [ {"id": "P", "unit": "W", "value": 700.0}, ], }, { "moduleid": "devices:local:pv2", "processdata": [ {"id": "P", "unit": "W", "value": 300.0}, ], }, ], ) values = await pykoplenti_extended_client.get_process_data_values( {"_virt_": ["pv_P"], "devices:local:pv1": ["P"], "devices:local:pv2": ["P"]} ) websession.request.assert_has_calls( [ call("GET", ANY, headers=ANY), call( "POST", ANY, headers=ANY, json=[ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, ], ), ], any_order=True, ) assert values == { "_virt_": pykoplenti.ProcessDataCollection( [pykoplenti.ProcessData(id="pv_P", unit="W", value=1000.0)] ), "devices:local:pv1": pykoplenti.ProcessDataCollection( [pykoplenti.ProcessData(id="P", unit="W", value=700.0)] ), "devices:local:pv2": pykoplenti.ProcessDataCollection( [pykoplenti.ProcessData(id="P", unit="W", value=300.0)] ), } @pytest.mark.asyncio async def test_virtual_process_data_not_all_requested( pykoplenti_extended_client: pykoplenti.ExtendedApiClient, client_response_factory: Callable[ [int, Union[list[Any], dict[Any, Any]]], MagicMock ], websession: MagicMock, ): """Test if not all available virtual process data are requested.""" client_response_factory( 200, [ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, { "moduleid": "scb:statistic:EnergyFlow", "processdataids": [ "Statistic:Yield:Total", "Statistic:EnergyHomeBat:Total", "Statistic:EnergyHomePv:Total", ], }, ], ) client_response_factory( 200, [ { "moduleid": "devices:local:pv1", "processdata": [ {"id": "P", "unit": "W", "value": 700.0}, ], }, { "moduleid": "devices:local:pv2", "processdata": [ {"id": "P", "unit": "W", "value": 300.0}, ], }, ], ) values = await pykoplenti_extended_client.get_process_data_values( {"_virt_": ["pv_P"]} ) websession.request.assert_has_calls( [ call("GET", ANY, headers=ANY), call( "POST", ANY, headers=ANY, json=[ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, ], ), ], any_order=True, ) assert values == { "_virt_": pykoplenti.ProcessDataCollection( [pykoplenti.ProcessData(id="pv_P", unit="W", value=1000.0)] ), } @pytest.mark.asyncio async def test_virtual_process_data_multiple_requested( pykoplenti_extended_client: pykoplenti.ExtendedApiClient, client_response_factory: Callable[ [int, Union[list[Any], dict[Any, Any]]], MagicMock ], websession: MagicMock, ): """Test if multiple virtual process data are requested.""" client_response_factory( 200, [ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, { "moduleid": "scb:statistic:EnergyFlow", "processdataids": [ "Statistic:Yield:Total", "Statistic:EnergyHomeBat:Total", "Statistic:EnergyHomePv:Total", ], }, ], ) client_response_factory( 200, [ { "moduleid": "devices:local:pv1", "processdata": [ {"id": "P", "unit": "W", "value": 700.0}, ], }, { "moduleid": "devices:local:pv2", "processdata": [ {"id": "P", "unit": "W", "value": 300.0}, ], }, { "moduleid": "scb:statistic:EnergyFlow", "processdata": [ { "id": "Statistic:Yield:Total", "unit": "Wh", "value": 1000.0, }, { "id": "Statistic:EnergyHomeBat:Total", "unit": "Wh", "value": 100.0, }, { "id": "Statistic:EnergyHomePv:Total", "unit": "Wh", "value": 200.0, }, ], }, ], ) values = await pykoplenti_extended_client.get_process_data_values( {"_virt_": ["pv_P", "Statistic:EnergyGrid:Total"]} ) websession.request.assert_has_calls( [ call("GET", ANY, headers=ANY), call( "POST", ANY, headers=ANY, json=_IterableMatcher( [ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, { "moduleid": "scb:statistic:EnergyFlow", "processdataids": _IterableMatcher( { "Statistic:Yield:Total", "Statistic:EnergyHomeBat:Total", "Statistic:EnergyHomePv:Total", } ), }, ] ), ), ], any_order=True, ) assert values == { "_virt_": pykoplenti.ProcessDataCollection( [ pykoplenti.ProcessData(id="pv_P", unit="W", value=1000.0), pykoplenti.ProcessData( id="Statistic:EnergyGrid:Total", unit="Wh", value=700.0 ), ] ), } stegm-pykoplenti-6537eba/tests/test_pykoplenti.py000066400000000000000000000156611477302610500224320ustar00rootroot00000000000000from datetime import datetime import json from typing import Any, Callable from unittest.mock import ANY, MagicMock import pytest import pykoplenti def test_me_parsing(): raw_response = """\ { "role": "NONE", "anonymous": true, "locked": false, "permissions": [], "active": false, "authenticated": false }""" me = pykoplenti.MeData(**json.loads(raw_response)) assert me.is_locked is False assert me.is_active is False assert me.is_authenticated is False assert me.permissions == [] assert me.is_anonymous is True assert me.role == "NONE" def test_version_parsing(): raw_response = """\ { "sw_version": "01.26.09454", "name": "PUCK RESTful API", "api_version": "0.2.0", "hostname": "scb" }""" version = pykoplenti.VersionData(**json.loads(raw_response)) assert version.api_version == "0.2.0" assert version.hostname == "scb" assert version.name == "PUCK RESTful API" assert version.sw_version == "01.26.09454" def test_event_parsing(): raw_response = """\ { "description": "Reduction of AC power due to external command.", "category": "info", "is_active": false, "code": 5014, "end_time": "2023-04-29T00:45:19", "start_time": "2023-04-29T00:44:18", "group": "Information", "long_description": "Reduction of AC power due to external command." }""" event = pykoplenti.EventData(**json.loads(raw_response)) assert event.start_time == datetime(2023, 4, 29, 0, 44, 18) assert event.end_time == datetime(2023, 4, 29, 0, 45, 19) assert event.is_active is False assert event.code == 5014 assert event.long_description == "Reduction of AC power due to external command." assert event.category == "info" assert event.description == "Reduction of AC power due to external command." assert event.group == "Information" def test_module_parsing(): raw_response = """\ { "id": "devices:local:powermeter", "type": "device:powermeter" }""" module = pykoplenti.ModuleData(**json.loads(raw_response)) assert module.id == "devices:local:powermeter" assert module.type == "device:powermeter" def test_process_parsing(): raw_response = """\ { "id": "Inverter:State", "unit": "", "value": 6 }""" process_data = pykoplenti.ProcessData(**json.loads(raw_response)) assert process_data.id == "Inverter:State" assert process_data.unit == "" assert process_data.value == 6 def test_settings_parsing(): raw_response = """\ { "min": "0", "default": null, "access": "readonly", "unit": null, "id": "Properties:PowerId", "type": "uint32", "max": "100000" }""" settings_data = pykoplenti.SettingsData(**json.loads(raw_response)) assert settings_data.unit is None assert settings_data.default is None assert settings_data.id == "Properties:PowerId" assert settings_data.max == "100000" assert settings_data.min == "0" assert settings_data.type == "uint32" assert settings_data.access == "readonly" def test_process_data_list(): json = [ {"id": "Statistic:Yield:Day", "unit": "%", "value": 1}, {"id": "Statistic:Yield:Month", "unit": "%", "value": 2}, ] assert pykoplenti.model.process_data_list(json) == [ pykoplenti.ProcessData(id="Statistic:Yield:Day", unit="%", value="1"), pykoplenti.ProcessData(id="Statistic:Yield:Month", unit="%", value="2"), ] def test_process_data_collection_indicates_length(): raw_response = ( '[{"id": "Statistic:Yield:Day", "unit": "", "value": 1}, ' '{"id": "Statistic:Yield:Month", "unit": "", "value": 2}]' ) pdc = pykoplenti.ProcessDataCollection( pykoplenti.model.process_data_list(json.loads(raw_response)) ) assert len(pdc) == 2 def test_process_data_collection_index_returns_processdata(): raw_response = ( '[{"id": "Statistic:Yield:Day", "unit": "", "value": 1}, ' '{"id": "Statistic:Yield:Month", "unit": "", "value": 2}]' ) pdc = pykoplenti.ProcessDataCollection( pykoplenti.model.process_data_list(json.loads(raw_response)) ) result = pdc["Statistic:Yield:Month"] assert isinstance(result, pykoplenti.ProcessData) assert result.id == "Statistic:Yield:Month" assert result.unit == "" assert result.value == 2 def test_process_data_collection_can_be_iterated(): raw_response = ( '[{"id": "Statistic:Yield:Day", "unit": "", "value": 1}, ' '{"id": "Statistic:Yield:Month", "unit": "", "value": 2}]' ) pdc = pykoplenti.ProcessDataCollection( pykoplenti.model.process_data_list(json.loads(raw_response)) ) result = list(pdc) assert result == ["Statistic:Yield:Day", "Statistic:Yield:Month"] @pytest.mark.asyncio async def test_relogin_on_401_response( pykoplenti_client: MagicMock, client_response_factory: Callable[[int, Any], MagicMock], ): """Ensures that a re-login is executed if a 401 response was returned.""" # First response returns 401 client_response_factory(401, None) # Second response is successfull client_response_factory( 200, [ { "moduleid": "moda", "processdata": [{"id": "procb", "unit": "", "value": 0}], } ], ) _ = await pykoplenti_client.get_process_data_values("moda", "procb") pykoplenti_client._login.assert_awaited_once() @pytest.mark.asyncio async def test_process_data_value( pykoplenti_client: MagicMock, client_response_factory: Callable[[int, Any], MagicMock], websession: MagicMock, ): """Test if process data values could be retrieved.""" client_response_factory( 200, [ { "moduleid": "devices:local:pv1", "processdata": [ {"id": "P", "unit": "W", "value": 700.0}, ], }, { "moduleid": "devices:local:pv2", "processdata": [ {"id": "P", "unit": "W", "value": 300.0}, ], }, ], ) values = await pykoplenti_client.get_process_data_values( {"devices:local:pv1": ["P"], "devices:local:pv2": ["P"]} ) websession.request.assert_called_once_with( "POST", ANY, headers=ANY, json=[ {"moduleid": "devices:local:pv1", "processdataids": ["P"]}, {"moduleid": "devices:local:pv2", "processdataids": ["P"]}, ], ) assert values == { "devices:local:pv1": pykoplenti.ProcessDataCollection( [pykoplenti.ProcessData(id="P", unit="W", value=700.0)] ), "devices:local:pv2": pykoplenti.ProcessDataCollection( [pykoplenti.ProcessData(id="P", unit="W", value=300.0)] ), } stegm-pykoplenti-6537eba/tests/test_smoketest.py000066400000000000000000000175271477302610500222550ustar00rootroot00000000000000"""Smoketest which are executed on a real inverter.""" import os import re from typing import AsyncGenerator import aiohttp import pytest import pytest_asyncio import pykoplenti @pytest_asyncio.fixture async def authenticated_client() -> AsyncGenerator[pykoplenti.ApiClient, None]: host = os.getenv("SMOKETEST_HOST", "localhost") port = int(os.getenv("SMOKETEST_PORT", 80)) password = os.getenv("SMOKETEST_PASS", "") async with aiohttp.ClientSession() as session: client = pykoplenti.ExtendedApiClient(session, host, port) await client.login(password) yield client await client.logout() @pytest.mark.skipif( os.getenv("SMOKETEST_HOST") is None, reason="Smoketest must be explicitly executed" ) class TestSmokeTests: """Contains smoke tests which are executed on a real inverter. This tests are not automatically executed because they need real HW. Please note that all checks are highl volatile because of different configuration and firmware version of the inverter. """ @pytest.mark.asyncio async def test_smoketest_me(self, authenticated_client: pykoplenti.ApiClient): """Retrieves the MeData.""" me = await authenticated_client.get_me() assert me == pykoplenti.MeData( locked=False, active=True, authenticated=True, permissions=[], anonymous=False, role="USER", ) @pytest.mark.asyncio async def test_smoketest_version(self, authenticated_client: pykoplenti.ApiClient): """Retrieves the VersionData.""" version = await authenticated_client.get_version() # version info are highly variable hence only some basic checks are performed assert len(version.hostname) > 0 assert len(version.name) > 0 assert re.match(r"\d+.\d+.\d+", version.api_version) is not None assert re.match(r"\d+.\d+.\d+", version.sw_version) is not None @pytest.mark.asyncio async def test_smoketest_modules(self, authenticated_client: pykoplenti.ApiClient): """Retrieves the ModuleData.""" modules = list(await authenticated_client.get_modules()) assert len(modules) >= 17 assert pykoplenti.ModuleData(id="devices:local", type="device") in modules @pytest.mark.asyncio async def test_smoketest_settings(self, authenticated_client: pykoplenti.ApiClient): """Retrieves the SettingsData.""" settings = await authenticated_client.get_settings() assert "devices:local" in settings assert ( pykoplenti.SettingsData( min="0", max="32", default=None, access="readonly", unit=None, id="Branding:ProductName1", type="string", ) in settings["devices:local"] ) @pytest.mark.asyncio async def test_smoketest_setting_value1( self, authenticated_client: pykoplenti.ApiClient ): """Retrieves the setting value with variante 1.""" setting_value = await authenticated_client.get_setting_values( "devices:local", "Branding:ProductName1" ) assert setting_value == { "devices:local": {"Branding:ProductName1": "PLENTICORE plus"} } @pytest.mark.asyncio async def test_smoketest_setting_value2( self, authenticated_client: pykoplenti.ApiClient ): """Retrieves the setting value with variante 2.""" setting_value = await authenticated_client.get_setting_values( "devices:local", ["Branding:ProductName1"] ) assert setting_value == { "devices:local": {"Branding:ProductName1": "PLENTICORE plus"} } @pytest.mark.asyncio @pytest.mark.skip(reason="API endpoint is not working") async def test_smoketest_setting_value3( self, authenticated_client: pykoplenti.ApiClient ): """Retrieves the setting value with variante 3.""" setting_value = await authenticated_client.get_setting_values( "devices:local" ) assert ( setting_value["devices:local"]["Branding:ProductName1"] == "PLENTICORE plus" ) @pytest.mark.asyncio async def test_smoketest_setting_value4( self, authenticated_client: pykoplenti.ApiClient ): """Retrieves the setting value with variante 4.""" setting_value = await authenticated_client.get_setting_values( {"devices:local": ["Branding:ProductName1"]} ) assert setting_value == { "devices:local": {"Branding:ProductName1": "PLENTICORE plus"} } @pytest.mark.asyncio async def test_smoketest_process_data_value1( self, authenticated_client: pykoplenti.ApiClient ): """Retrieves process data values by using str, str variant.""" process_data = await authenticated_client.get_process_data_values( "devices:local", "EM_State" ) assert process_data.keys() == {"devices:local"} assert len(process_data["devices:local"]) == 1 assert process_data["devices:local"]["EM_State"] is not None @pytest.mark.asyncio async def test_smoketest_process_data_value2( self, authenticated_client: pykoplenti.ApiClient ): """Retrieves process data values by using str, Iterable[str] variant.""" process_data = await authenticated_client.get_process_data_values( "devices:local", ["EM_State", "Inverter:State"] ) assert process_data.keys() == {"devices:local"} assert len(process_data["devices:local"]) == 2 assert process_data["devices:local"]["EM_State"] is not None assert process_data["devices:local"]["Inverter:State"] is not None @pytest.mark.asyncio async def test_smoketest_process_data_value3( self, authenticated_client: pykoplenti.ApiClient ): """Retrieves process data values by using Dict[str, Iterable[str]] variant.""" process_data = await authenticated_client.get_process_data_values( { "devices:local": ["EM_State", "Inverter:State"], "scb:export": ["PortalConActive"], } ) assert process_data.keys() == {"devices:local", "scb:export"} assert len(process_data["devices:local"]) == 2 assert process_data["devices:local"]["EM_State"] is not None assert process_data["devices:local"]["Inverter:State"] is not None assert len(process_data["scb:export"]) == 1 assert process_data["scb:export"]["PortalConActive"] is not None @pytest.mark.asyncio async def test_smoketest_read_all_process_values( self, authenticated_client: pykoplenti.ApiClient ): """Try to read all process values and ensure no exception is thrown.""" process_data = await authenticated_client.get_process_data() for module_id, processdata_ids in process_data.items(): processdata_values = await authenticated_client.get_process_data_values( module_id, processdata_ids ) assert len(processdata_values) == 1 assert module_id in processdata_values assert set(processdata_ids) == set(processdata_values[module_id]) assert all( isinstance(x.unit, str) for x in processdata_values[module_id].values() ) assert all( isinstance(x.value, float) for x in processdata_values[module_id].values() ) @pytest.mark.asyncio async def test_smoketest_read_events( self, authenticated_client: pykoplenti.ApiClient ): """Try to read events from the inverter.""" events = await authenticated_client.get_events() for event in events: assert event.start_time < event.end_time stegm-pykoplenti-6537eba/tox.ini000066400000000000000000000004371477302610500167670ustar00rootroot00000000000000[tox] envlist = py3{9,10,11,12}-pydantic{1,2} [testenv] description = Executes pytest deps = pytest~=7.4 pytest-asyncio~=0.21 pytest-cov~=4.1 prompt-toolkit~=3.0 click~=8.0 pydantic1: pydantic~=1.10 pydantic2: pydantic~=2.6 set_env = file|.env commands = pytest -Werror