From 782ec13067b6cdf6f011635e0d3019ceec39a936 Mon Sep 17 00:00:00 2001 From: Layf <93056040+Layf21@users.noreply.github.com> Date: Tue, 9 Sep 2025 15:44:00 +0200 Subject: [PATCH 01/12] Clean up build system. Switched from Setuptools to hatch. Switched from CalVer to SemVer. --- .gitignore | 220 +++++++++++++++- pyproject.toml | 42 ++- setup.cfg | 23 -- src/froeling_connect.egg-info/PKG-INFO | 245 ------------------ src/froeling_connect.egg-info/SOURCES.txt | 20 -- .../dependency_links.txt | 1 - src/froeling_connect.egg-info/requires.txt | 1 - src/froeling_connect.egg-info/top_level.txt | 1 - 8 files changed, 250 insertions(+), 303 deletions(-) delete mode 100644 setup.cfg delete mode 100644 src/froeling_connect.egg-info/PKG-INFO delete mode 100644 src/froeling_connect.egg-info/SOURCES.txt delete mode 100644 src/froeling_connect.egg-info/dependency_links.txt delete mode 100644 src/froeling_connect.egg-info/requires.txt delete mode 100644 src/froeling_connect.egg-info/top_level.txt diff --git a/.gitignore b/.gitignore index 1a19bfd..9ec0b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,216 @@ -venv -dist -.idea -.env \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/pyproject.toml b/pyproject.toml index 3a8586c..94ac990 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,10 @@ [build-system] -requires = [ - "setuptools>=42", - "wheel" -] -build-backend = "setuptools.build_meta" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] name = "froeling-connect" -version = "2024.1.3" +version = "0.1.3" description = "A python wrapper for the Fröling-Connect API" readme = "README.md" requires-python = ">=3.9" @@ -18,5 +15,34 @@ authors = [ ] dependencies = [ - "aiohttp", -] \ No newline at end of file + "aiohttp>=3", +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development", + "Typing :: Typed", +] + +[project.urls] +homepage = "https://github.com/Layf21/froeling-connect" +GitHub = "https://github.com/Layf21/froeling-connect" +source = "https://github.com/Layf21/froeling-connect.git" +docs = "https://github.com/Layf21/froeling-connect/blob/master/README.md" +issues = "https://github.com/Layf21/froeling-connect/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/froeling"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7348369..0000000 --- a/setup.cfg +++ /dev/null @@ -1,23 +0,0 @@ -[metadata] -name = froeling-connect -version = 2024.1.3 -author = Layf -description = A python wrapper for the Fröling-Connect API -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/Layf21/froeling-connect -project_urls = - Bug Tracker = https://github.com/Layf21/froeling-connect/issues -classifiers = - Programming Language :: Python :: 3 - License :: OSI Approved :: Apache Software License - Operating System :: OS Independent - -[options] -package_dir = - = src -packages = find: -python_requires = >=3.9 - -[options.packages.find] -where = src \ No newline at end of file diff --git a/src/froeling_connect.egg-info/PKG-INFO b/src/froeling_connect.egg-info/PKG-INFO deleted file mode 100644 index 6c32f4e..0000000 --- a/src/froeling_connect.egg-info/PKG-INFO +++ /dev/null @@ -1,245 +0,0 @@ -Metadata-Version: 2.1 -Name: froeling-connect -Version: 2024.1.2 -Summary: A python wrapper for the Fröling-Connect API -Home-page: https://github.com/Layf21/froeling-connect -Author: Layf -License: 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. - - - 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. - -Keywords: froeling,fröling,fröling-connect,fröling connect -Requires-Python: >=3.9 -Description-Content-Type: text/markdown -License-File: LICENSE.txt -Requires-Dist: aiohttp - -# froeling-connect -An inofficial asynchronous implementation of the proprietary [fröling-connect](https://connect-web.froeling.com/) web API. - - -## Disclaimer ->This library was only tested with the T4e Boiler, it may not work perfectly for other Machines. ->As this API is not public, there may be breaking changes on the backend. ->### I am not affiliated, associated, authorized, endorsed by, or in any way officially connected with Fröling Heizkessel- und Behälterbau Ges.m.b.H. ->Their official website can be found at https://www.froeling.com. - -## Features -* Read notifications -* Get general information about facilities and components managed by the user -* Get and set parameters (not tested for all parameters) - -## Installation - -```py -m pip install froeling-connect``` -(May not be up to date, this is my first time using PyPI) - -## Terminology - -|Name | Description | Examples | -|----------|---------------------------------------------------------------------------|---------------------------| -|Facility | The Heating-Installation. One User can manage multiple Facilities. | Wood Chip Boiler T4e | -|Component | A facility consists of multiple Components. | Boiler, Heating circuit | -|Parameter | Components have multiple parameters. These are measurements and settings. | Boiler State, Water Temp. | - - -## Usage -There is no documentation currently. -Example usage can be found [here](https://github.com/Layf21/froeling-connect/blob/master/example.py) diff --git a/src/froeling_connect.egg-info/SOURCES.txt b/src/froeling_connect.egg-info/SOURCES.txt deleted file mode 100644 index 6babe11..0000000 --- a/src/froeling_connect.egg-info/SOURCES.txt +++ /dev/null @@ -1,20 +0,0 @@ -LICENSE.txt -README.md -pyproject.toml -setup.cfg -src/froeling/__init__.py -src/froeling/client.py -src/froeling/endpoints.py -src/froeling/exceptions.py -src/froeling/session.py -src/froeling/datamodels/__init__.py -src/froeling/datamodels/component.py -src/froeling/datamodels/facility.py -src/froeling/datamodels/generics.py -src/froeling/datamodels/notifications.py -src/froeling/datamodels/userdata.py -src/froeling_connect.egg-info/PKG-INFO -src/froeling_connect.egg-info/SOURCES.txt -src/froeling_connect.egg-info/dependency_links.txt -src/froeling_connect.egg-info/requires.txt -src/froeling_connect.egg-info/top_level.txt \ No newline at end of file diff --git a/src/froeling_connect.egg-info/dependency_links.txt b/src/froeling_connect.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/froeling_connect.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/froeling_connect.egg-info/requires.txt b/src/froeling_connect.egg-info/requires.txt deleted file mode 100644 index ee4ba4f..0000000 --- a/src/froeling_connect.egg-info/requires.txt +++ /dev/null @@ -1 +0,0 @@ -aiohttp diff --git a/src/froeling_connect.egg-info/top_level.txt b/src/froeling_connect.egg-info/top_level.txt deleted file mode 100644 index a5337d6..0000000 --- a/src/froeling_connect.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -froeling From 9f9615dc1b43888e6e7a2cd849bf1256e08076c8 Mon Sep 17 00:00:00 2001 From: Layf <93056040+Layf21@users.noreply.github.com> Date: Tue, 9 Sep 2025 21:50:58 +0200 Subject: [PATCH 02/12] Added pre-commit linting and typechecks --- .pre-commit-config.yaml | 22 ++++++++++++++++++++++ README.md | 13 ++++++------- pyproject.toml | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..43d0ab8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + - id: check-ast + - id: check-toml + - id: check-merge-conflict + - id: trailing-whitespace + - id: end-of-file-fixer +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.12 + hooks: + - id: ruff-check + args: [ "--fix" ] + - id: ruff-format +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.17.1 + hooks: + - id: mypy diff --git a/README.md b/README.md index 7b5797f..b681f48 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # froeling-connect -An inofficial asynchronous implementation of the proprietary [fröling-connect](https://connect-web.froeling.com/) web API. +An inofficial asynchronous implementation of the proprietary [fröling-connect](https://connect-web.froeling.com/) web API. -## Disclaimer ->This library was only tested with the T4e Boiler, it may not work perfectly for other Machines. ->As this API is not public, there may be breaking changes on the backend. +## Disclaimer +>This library was only tested with the T4e Boiler, it may not work perfectly for other Machines. +>As this API is not public, there may be breaking changes on the backend. >### I am not affiliated, associated, authorized, endorsed by, or in any way officially connected with Fröling Heizkessel- und Behälterbau Ges.m.b.H. >Their official website can be found at https://www.froeling.com. @@ -16,7 +16,6 @@ An inofficial asynchronous implementation of the proprietary [fröling-connect]( ## Installation ```py -m pip install froeling-connect``` -(May not be up to date, this is my first time using PyPI) ## Terminology @@ -28,5 +27,5 @@ An inofficial asynchronous implementation of the proprietary [fröling-connect]( ## Usage -There is no documentation currently. -Example usage can be found [here](https://github.com/Layf21/froeling-connect/blob/master/example.py) \ No newline at end of file +There is no documentation currently. +Example usage can be found [here](https://github.com/Layf21/froeling-connect/blob/master/example.py) diff --git a/pyproject.toml b/pyproject.toml index 94ac990..ed3bb6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -44,5 +43,37 @@ source = "https://github.com/Layf21/froeling-connect.git" docs = "https://github.com/Layf21/froeling-connect/blob/master/README.md" issues = "https://github.com/Layf21/froeling-connect/issues" + [tool.hatch.build.targets.wheel] packages = ["src/froeling"] + +[tool.hatch.envs.dev] +dependencies = [ + "pre-commit", + "mypy", + "ruff", + "pytest", +] + + +[tool.ruff] +lint.select = ["D", "E", "F", "N", "Q"] + +[tool.ruff.lint.flake8-quotes] +inline-quotes = "single" +multiline-quotes = "single" +docstring-quotes = "double" +avoid-escape = true + + +[tool.mypy] +python_version = "3.10" +warn_unused_configs = true + +disallow_untyped_defs = true +disallow_incomplete_defs = true +ignore_missing_imports = true + +strict_optional = true +warn_redundant_casts = true +allow_untyped_globals = false From 51c04bdefd08ded95f2c58dd568c752e3155f4e9 Mon Sep 17 00:00:00 2001 From: Layf <93056040+Layf21@users.noreply.github.com> Date: Wed, 10 Sep 2025 15:37:11 +0200 Subject: [PATCH 03/12] Fix ruff config --- .pre-commit-config.yaml | 3 +-- pyproject.toml | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43d0ab8..54adf8a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,9 +13,8 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.12.12 hooks: - - id: ruff-check - args: [ "--fix" ] - id: ruff-format + - id: ruff-check - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.17.1 hooks: diff --git a/pyproject.toml b/pyproject.toml index ed3bb6f..027d8f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,11 +57,16 @@ dependencies = [ [tool.ruff] +include = ["src/**/*.py"] lint.select = ["D", "E", "F", "N", "Q"] +[tool.ruff.format] +quote-style = "single" +indent-style = "space" + [tool.ruff.lint.flake8-quotes] inline-quotes = "single" -multiline-quotes = "single" +multiline-quotes = "double" docstring-quotes = "double" avoid-escape = true From 892d1841eb544b7f687426d7e30c561871598ae0 Mon Sep 17 00:00:00 2001 From: Layf <93056040+Layf21@users.noreply.github.com> Date: Wed, 10 Sep 2025 15:38:31 +0200 Subject: [PATCH 04/12] Chores to please linter. Introduced breaking changes by using snake case everywhere. --- src/froeling/__init__.py | 26 +++- src/froeling/client.py | 149 +++++++++++++----- src/froeling/datamodels/__init__.py | 12 ++ src/froeling/datamodels/component.py | 186 ++++++++++++++++------- src/froeling/datamodels/facility.py | 134 +++++++++++----- src/froeling/datamodels/generics.py | 80 +++++++--- src/froeling/datamodels/notifications.py | 131 +++++++++------- src/froeling/datamodels/userdata.py | 72 +++++---- src/froeling/endpoints.py | 10 +- src/froeling/exceptions.py | 51 ++++++- src/froeling/session.py | 147 +++++++++++++----- 11 files changed, 707 insertions(+), 291 deletions(-) diff --git a/src/froeling/__init__.py b/src/froeling/__init__.py index 5ad2d5c..e9f5f30 100644 --- a/src/froeling/__init__.py +++ b/src/froeling/__init__.py @@ -1,11 +1,29 @@ -"""Fröling connect API Wrapper +"""Fröling connect API Wrapper. This library is an unofficial API wrapper for the Fröling Web Portal (https://connect-web.froeling.com/). -As for now, this wrapper is read only. Altering settings is not implemented yet. It supports reading statistics from -your devices and reading notifications. +Some features are not yet implemented, like remote ignition for example. Github and documentation: https://https://github.com/Layf21/froeling-connect.py """ from .client import Froeling -from .datamodels import Facility, Component, Parameter, UserData, NotificationOverview, NotificationDetails, Address +from .datamodels import ( + Facility, + Component, + Parameter, + UserData, + NotificationOverview, + NotificationDetails, + Address, +) + +__all__ = [ + 'Froeling', + 'Facility', + 'Component', + 'Parameter', + 'UserData', + 'NotificationOverview', + 'NotificationDetails', + 'Address', +] diff --git a/src/froeling/client.py b/src/froeling/client.py index 741fbc6..c04fa33 100644 --- a/src/froeling/client.py +++ b/src/froeling/client.py @@ -1,4 +1,7 @@ -"""Provides the main API Class""" +"""Provides the main API Class.""" + +from types import TracebackType +from typing import Optional, Type, Callable, Any import logging from aiohttp import ClientSession @@ -14,85 +17,149 @@ class Froeling: _userdata: datamodels.UserData _facilities: dict[int, datamodels.Facility] = {} - - async def __aenter__(self): + async def __aenter__(self) -> 'Froeling': + """Create an API session.""" if not self.session.token: await self.login() return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Optional[bool]: + """End an API session.""" await self.session.close() - - def __init__(self, username: str = None, password: str = None, token: str = None, auto_reauth: bool = False, - token_callback=None, language: str = 'en', logger: logging.Logger = None, clientsession: ClientSession = None): - """Initialize a :class:`Froeling` instance. - Either username and password or a token is required. - :param username: The email you use to log into your Fröling account. - :param password: Your Fröling password (not required when using token). - :param token: A valid token (not required when using username and password). - :param auto_reauth: Automatically fetches a new token if the current one expires (requires password and username). - :param max_retries: How often to retry a request if the request failed. - :param token_callback: A function that is called when the token gets renewed (useful for saving the token). - :param clientsession: When provided, network communication will go through this aiohttp session.""" - - self.session = Session(username, password, token, auto_reauth, token_callback, language, logger, clientsession) + return None + + def __init__( + self, + username: str | None = None, + password: str | None = None, + token: str | None = None, + auto_reauth: bool = False, + token_callback: Callable[[str], Any] | None = None, + language: str = 'en', + logger: logging.Logger | None = None, + clientsession: ClientSession | None = None, + ) -> None: + """Initialize a Froeling API client instance. + + Either `username` and `password` or a `token` is required. + + Args: + username (str | None): Email used to log into your Fröling account. + password (str | None): Fröling password (not required when using `token`). + token (str | None): Valid token (not required when using username/password). + auto_reauth (bool): Automatically fetch a new token if the current one + expires (requires username and password). Defaults to False. + token_callback (Callable[[str], Any] | None): Function called when the token + is renewed (useful for saving the token). Defaults to None. + language (str): Preferred language for API responses. Defaults to "en". + logger (logging.Logger | None): Logger for debugging and events. + Defaults to None. + clientsession (ClientSession | None): Optional aiohttp session to reuse + instead of creating a new one. Defaults to None. + + """ + self.session = Session( + username, + password, + token, + auto_reauth, + token_callback, + language, + logger, + clientsession, + ) self._logger = logger or logging.getLogger(__name__) async def login(self) -> datamodels.UserData: + """Log in with the username and password.""" data = await self.session.login() - self._userdata = datamodels.UserData.from_dict(data) + self._userdata = datamodels.UserData._from_dict(data) return self._userdata - async def close(self): + async def close(self) -> None: + """Close the session.""" await self.session.close() @property - def user_id(self): + def user_id(self) -> int | None: + """The user's id.""" return self.session.user_id @property - def token(self): + def token(self) -> str | None: + """The user's token.""" return self.session.token async def _get_userdata(self) -> datamodels.UserData: - res = await self.session.request("get", endpoints.USER.format(self.session.user_id)) - return datamodels.UserData.from_dict(res) - async def get_userdata(self): - """Gets userdata (cached)""" + """Fetch userdata (cached).""" + res = await self.session.request( + 'get', endpoints.USER.format(self.session.user_id) + ) + return datamodels.UserData._from_dict(res) + + async def get_userdata(self) -> datamodels.UserData: + """Get userdata (cached).""" if not self._userdata: self._userdata = await self._get_userdata() return self._userdata - async def _get_facilities(self) -> list[datamodels.Facility]: - """Gets all facilities connected with the account and stores them in this.facilities.""" - res = await self.session.request("get", endpoints.FACILITY.format(self.session.user_id)) - return datamodels.Facility.from_list(res, self.session) + """Fetch all facilities connected with the account and cache them.""" + res = await self.session.request( + 'get', endpoints.FACILITY.format(self.session.user_id) + ) + return datamodels.Facility._from_list(res, self.session) async def get_facilities(self) -> list[datamodels.Facility]: + """Get all cacilities connected with this account (cached).""" if not self._facilities: facilities = await self._get_facilities() self._facilities = {f.facility_id: f for f in facilities} return list(self._facilities.values()) - async def get_facility(self, facility_id) -> datamodels.Facility: + async def get_facility(self, facility_id: int) -> datamodels.Facility: + """Get a specific facility given it's id (cached).""" if facility_id not in self._facilities: await self.get_facilities() - assert facility_id in self._facilities, f"Facility with id {facility_id} not found." + assert facility_id in self._facilities, ( + f'Facility with id {facility_id} not found.' + ) return self._facilities[facility_id] - async def get_notification_count(self) -> int: - """Gets unread notification count""" - return (await self.session.request("get", endpoints.NOTIFICATION_COUNT.format(self.session.user_id)))["unreadNotifications"] + """Fetch the unread notification count.""" + return ( + await self.session.request( + 'get', endpoints.NOTIFICATION_COUNT.format(self.session.user_id) + ) + )['unreadNotifications'] async def get_notifications(self) -> list[datamodels.NotificationOverview]: - res = await self.session.request("get", endpoints.NOTIFICATION_LIST.format(self.session.user_id)) + """Fetch an overview of all notifications.""" + res = await self.session.request( + 'get', endpoints.NOTIFICATION_LIST.format(self.session.user_id) + ) return [datamodels.NotificationOverview(n, self.session) for n in res] - async def get_notification(self, notification_id: int) -> datamodels.NotificationDetails: - res = await self.session.request("get", endpoints.NOTIFICATION.format(self.session.user_id, notification_id)) - return datamodels.NotificationDetails.from_dict(res) - - def get_component(self, facility_id: int, component_id: str): + async def get_notification( + self, notification_id: int + ) -> datamodels.NotificationDetails: + """Fetch all details for a specific notification.""" + res = await self.session.request( + 'get', endpoints.NOTIFICATION.format(self.session.user_id, notification_id) + ) + return datamodels.NotificationDetails._from_dict(res) + + def get_component( + self, facility_id: int, component_id: str + ) -> datamodels.Component: + """Get a specific component given it's facility_id and component_id. + + Call the update method for this component to populate it's attributes. + """ return datamodels.Component(facility_id, component_id, self.session) diff --git a/src/froeling/datamodels/__init__.py b/src/froeling/datamodels/__init__.py index ad3d2b9..73d356e 100644 --- a/src/froeling/datamodels/__init__.py +++ b/src/froeling/datamodels/__init__.py @@ -1,4 +1,16 @@ +"""Datamodels to represent the API objects in python.""" + from .userdata import UserData, Address from .notifications import NotificationOverview, NotificationDetails from .facility import Facility from .component import Component, Parameter + +__all__ = [ + 'UserData', + 'Address', + 'NotificationOverview', + 'NotificationDetails', + 'Facility', + 'Component', + 'Parameter', +] diff --git a/src/froeling/datamodels/component.py b/src/froeling/datamodels/component.py index a6979cc..ba20446 100644 --- a/src/froeling/datamodels/component.py +++ b/src/froeling/datamodels/component.py @@ -1,3 +1,6 @@ +"""Represents Components and their Parameters.""" + +from typing import Any from dataclasses import dataclass from .. import endpoints @@ -5,53 +8,94 @@ from ..exceptions import NetworkError from .generics import TimeWindowDay + class Component: - """Represents a component. Contains its parameters. Remember to call Component.update to populate values.""" + """Represents a facility component. + + A `Component` may be partially or fully populated depending on + how it was fetched. + + - `get_component` methods only set `facility_id` and `component_id`. + - `Facility.get_components` populates overview data such as + `display_name`, `display_category`, `standard_name`, `type`, + and `sub_type`. + - Call `Component.update()` to load all available values, including + parameters and other detailed fields. + + Attributes: + facility_id (int): ID of the facility this component belongs to. + component_id (str): Unique identifier for the component. + display_name (str | None): Human-readable name of the component. + display_category (str | None): High-level category for grouping components. + standard_name (str | None): Standardized name, if available. + type (str | None): Component type. + sub_type (str | None): More specific component subtype. + time_windows_view (list[TimeWindowDay] | None): Time window data, if fetched. + picture_url (str | None): URL to a representative image of the component. + parameters (list[Parameter]): List of associated parameters. + + """ + + facility_id: int component_id: str - display_name: str - display_category: str - standard_name: str - type: str - sub_type: str - time_windows_view: list[TimeWindowDay] - picture_url: str + display_name: str | None + display_category: str | None + standard_name: str | None + type: str | None + sub_type: str | None + time_windows_view: list[TimeWindowDay] | None + picture_url: str | None parameters: list['Parameter'] def __init__(self, facility_id: int, component_id: str, session: Session): + """Initialize a Component with minimal identifying information.""" self.facility_id = facility_id self.component_id = component_id - self.session = session + self._session = session @classmethod - def from_overview_data(cls, facility_id: int, session: Session, obj: dict) -> 'Component': - component = cls(facility_id, obj.get("componentId"), session) - component.display_name = obj.get("displayName") - component.display_category = obj.get("displayCategory") - component.standard_name = obj.get("standardName") - component.type = obj.get("type") - component.sub_type = obj.get("subType") + def _from_overview_data( + cls, facility_id: int, session: Session, obj: dict + ) -> 'Component' | None: + """Create a new component and populate it with overview data.""" + component_id = obj.get('componentId') + if not isinstance(component_id, str): + return None + + component = cls(facility_id, component_id, session) + component.display_name = obj.get('displayName') + component.display_category = obj.get('displayCategory') + component.standard_name = obj.get('standardName') + component.type = obj.get('type') + component.sub_type = obj.get('subType') return component - - def __str__(self): + def __str__(self) -> str: + """Return a string representation of this component.""" return f'Component([Facility {self.facility_id}] -> {self.component_id})' async def update(self) -> list['Parameter']: - res = await self.session.request("get", endpoints.COMPONENT.format(self.session.user_id, self.facility_id, self.component_id)) - self.component_id = res.get('componentId') + """Update the Parameters of this component.""" + res = await self._session.request( + 'get', + endpoints.COMPONENT.format( + self._session.user_id, self.facility_id, self.component_id + ), + ) + self.component_id = res.get('componentId') # This should not be able to change. self.display_name = res.get('displayName') self.display_category = res.get('displayCategory') self.standard_name = res.get('standardName') self.type = res.get('type') self.sub_type = res.get('subType') if res.get('timeWindowsView'): - self.time_windows_view = TimeWindowDay.from_list(res['timeWindowsView']) + self.time_windows_view = TimeWindowDay._from_list(res['timeWindowsView']) # TODO: Find endpoint that gives all parameters topview = res.get('topView') - parameters = dict() + parameters: dict[str, dict] = dict() if topview: self.picture_url = topview.get('pictureUrl') if 'pictureParams' in topview: @@ -65,60 +109,90 @@ async def update(self) -> list['Parameter']: if 'setupView' in res: parameters |= {i['name']: i for i in res.get('setupView')} - self.parameters = Parameter.from_list(parameters.values(), self.session, self.facility_id) + self.parameters = Parameter._from_list( + list(parameters.values()), self._session, self.facility_id + ) return self.parameters @dataclass class Parameter: + """Represents a parameter (a value) of a component.""" + session: Session facility_id: int - id: str - display_name: str - name: str - editable: bool - parameter_type: str - unit: str - value: str - min_val: str - max_val: str - string_list_key_values: dict[str, str] + id: str | None + display_name: str | None + name: str | None + editable: bool | None + parameter_type: str | None + unit: str | None + value: str | None + min_val: str | None + max_val: str | None + string_list_key_values: dict[str, str] | None @classmethod - def from_dict(cls, obj, session: Session, facility_id: int): - parameter_id = obj["id"] - display_name = obj.get("displayName") - name = obj.get("name") - editable = obj.get("editable") - parameter_type = obj.get("parameterType") - unit = obj.get("unit") - value = obj.get("value") - min_val = obj.get("minVal") - max_val = obj.get("maxVal") - string_list_key_values = obj.get("stringListKeyValues") - - return cls(session, facility_id, parameter_id, display_name, name, editable, parameter_type, unit, value, min_val, max_val, string_list_key_values) + def _from_dict(cls, obj: dict, session: Session, facility_id: int) -> 'Parameter': + parameter_id = obj['id'] + display_name = obj.get('displayName') + name = obj.get('name') + editable = obj.get('editable') + parameter_type = obj.get('parameterType') + unit = obj.get('unit') + value = obj.get('value') + min_val = obj.get('minVal') + max_val = obj.get('maxVal') + string_list_key_values = obj.get('stringListKeyValues') + + return cls( + session, + facility_id, + parameter_id, + display_name, + name, + editable, + parameter_type, + unit, + value, + min_val, + max_val, + string_list_key_values, + ) @classmethod - def from_list(cls, obj, session: Session, facility_id: int): - return [cls.from_dict(i, session, facility_id) for i in obj] + def _from_list( + cls, obj: list[dict], session: Session, facility_id: int + ) -> list['Parameter']: + """Turn a list of api response dicts into a list of parameter object.""" + return [cls._from_dict(i, session, facility_id) for i in obj] @property def display_value(self) -> str: + """Combine the value with it's unit.""" if self.string_list_key_values: return self.string_list_key_values[str(self.value)] if self.unit: return f'{self.value} {self.unit}' return str(self.value) - async def set_value(self, value): - """Returns None if value is the same""" + async def set_value(self, value: Any) -> Any | None: + """Set the value of this parameter. + + Be careful with this, don't change parameters if you don't know what they do. + You might want to check Parameter.editable together with this. + Returns None if the value was already the same. + """ try: - return await self.session.request('put', - endpoints.SET_PARAMETER.format(self.session.user_id, self.facility_id, self.id), - json={"value": str(value)} - ) + return await self.session.request( + 'put', + endpoints.SET_PARAMETER.format( + self.session.user_id, self.facility_id, self.id + ), + json={'value': str(value)}, + ) except NetworkError as e: - if e.status == 304: # unchanged - return None \ No newline at end of file + if e.status == 304: # unchanged + return None + raise e diff --git a/src/froeling/datamodels/facility.py b/src/froeling/datamodels/facility.py index 9c5b6d9..5afd8f3 100644 --- a/src/froeling/datamodels/facility.py +++ b/src/froeling/datamodels/facility.py @@ -1,59 +1,109 @@ +"""Dataclasses relating to Facilities.""" + from dataclasses import dataclass from ..session import Session from .. import endpoints from .generics import Address -from .component import Component, Parameter +from .component import Component + @dataclass(frozen=True) class Facility: + """Represents data related to a facility.""" + session: Session facility_id: int - equipmentNumber: int - status: str - name: str - address: Address - owner: str - role: str - favorite: bool - allowMessages: bool - subscribedNotifications: bool - pictureUrl: str - protocol3200Info: dict[str, str] - hoursSinceLastMaintenance: int - operationHours: int - facilityGeneration: str + equipment_number: int | None + status: str | None + name: str | None + address: Address | None + owner: str | None + role: str | None + favorite: bool | None + allow_messages: bool | None + subscribed_notifications: bool | None + picture_url: str | None + protocol_3200_info: dict[str, str] | None + hours_since_last_maintenance: int | None + operation_hours: int | None + facility_generation: str | None @staticmethod - def from_dict(obj: dict, session: Session) -> 'Facility': - facility_id = obj.get("facilityId") - equipmentNumber = obj.get("equipmentNumber") - status = obj.get("status") - name = obj.get("name") - address = Address.from_dict(obj.get("address")) - owner = obj.get("owner") - role = obj.get("role") - favorite = obj.get("favorite") - allowMessages = obj.get("allowMessages") - subscribedNotifications = obj.get("subscribedNotifications") - pictureUrl = obj.get("pictureUrl") - protocol3200Info = obj.get("protocol3200Info") - hoursSinceLastMaintenance = int(protocol3200Info.get("hoursSinceLastMaintenance")) - operationHours = int(protocol3200Info.get("operationHours")) - - - facilityGeneration = obj.get("facilityGeneration") - return Facility(session, facility_id, equipmentNumber, status, name, address, owner, role, favorite, allowMessages, - subscribedNotifications, pictureUrl, protocol3200Info, hoursSinceLastMaintenance, operationHours, facilityGeneration) + def _from_dict(obj: dict, session: Session) -> 'Facility': + facility_id = obj.get('facilityId') + assert isinstance(facility_id, int), f'facilityid was not an int.\nobj:{obj}' + equipment_number = obj.get('equipmentNumber') + status = obj.get('status') + name = obj.get('name') + address_data = obj.get('address') + address = ( + Address._from_dict(address_data) if isinstance(address_data, dict) else None + ) + owner = obj.get('owner') + role = obj.get('role') + favorite = obj.get('favorite') + allow_messages = obj.get('allowMessages') + subscribed_notifications = obj.get('subscribedNotifications') + picture_url = obj.get('pictureUrl') + + protocol_3200_info = obj.get('protocol3200Info') + + hours_since_last_maintenance: int | None = None + operation_hours: int | None = None + if isinstance(protocol_3200_info, dict): + hslm = protocol_3200_info.get('hoursSinceLastMaintenance') + if isinstance(hslm, (int, str)): + try: + hours_since_last_maintenance = int(hslm) + except ValueError: + hours_since_last_maintenance = None + + op_hours = protocol_3200_info.get('operationHours') + if isinstance(op_hours, (int, str)): + try: + operation_hours = int(op_hours) + except ValueError: + operation_hours = None + + facility_generation = obj.get('facilityGeneration') + return Facility( + session, + facility_id, + equipment_number, + status, + name, + address, + owner, + role, + favorite, + allow_messages, + subscribed_notifications, + picture_url, + protocol_3200_info, + hours_since_last_maintenance, + operation_hours, + facility_generation, + ) @staticmethod - def from_list(obj: list, session: Session): - return [Facility.from_dict(i, session) for i in obj] + def _from_list(obj: list, session: Session) -> list['Facility']: + return [Facility._from_dict(i, session) for i in obj] - async def get_components(self) -> list[Component]: - res = await self.session.request("get", endpoints.COMPONENT_LIST.format(self.session.user_id, self.facility_id)) - return [Component.from_overview_data(self.facility_id, self.session, i) for i in res] + async def get_components(self) -> list[Component | None]: + """Fetch all components of this facility (not cached).""" + res = await self.session.request( + 'get', + endpoints.COMPONENT_LIST.format(self.session.user_id, self.facility_id), + ) + return [ + Component._from_overview_data(self.facility_id, self.session, i) + for i in res # noqa: E501 + ] - def get_component(self, component_id: str): - return Component(self.facility_id, component_id, self.session) + def get_component(self, component_id: str) -> Component: + """Get a component given it's id. + Data will not be initialized, call the Component.update method to fetch them. + """ + return Component(self.facility_id, component_id, self.session) diff --git a/src/froeling/datamodels/generics.py b/src/froeling/datamodels/generics.py index a8dcd18..8561ebd 100644 --- a/src/froeling/datamodels/generics.py +++ b/src/froeling/datamodels/generics.py @@ -1,58 +1,94 @@ +"""Generic datamodels used in multiple places/endpoints.""" + from enum import Enum from dataclasses import dataclass + @dataclass(frozen=True) class Address: + """Represents a physical mailing address. + + Attributes: + street (str): Street name and number. + zip (int): Postal code. + city (str): City name. + country (str): Country name. + + """ + street: str zip: int city: str country: str @staticmethod - def from_dict(obj: dict) -> 'Address': - street = obj.get("street") - zip = obj.get("zip") - city = obj.get("city") - country = obj.get("country") - return Address(street, zip, city, country) + def _from_dict(obj: dict) -> 'Address': + street: str = obj.get('street', '') + zipcode: int = obj.get('zip', -1) + city: str = obj.get('city', '') + country: str = obj.get('country', '') + return Address(street, zipcode, city, country) class Weekday(Enum): - MONDAY = "MONDAY" - TUESDAY = "TUESDAY" - WEDNESDAY = "WEDNESDAY" - THURSDAY = "THURSDAY" - FRIDAY = "FRIDAY" - SATURDAY = "SATURDAY" - SUNDAY = "SUNDAY" + """Enumeration of the days of the week.""" + + MONDAY = 'MONDAY' + TUESDAY = 'TUESDAY' + WEDNESDAY = 'WEDNESDAY' + THURSDAY = 'THURSDAY' + FRIDAY = 'FRIDAY' + SATURDAY = 'SATURDAY' + SUNDAY = 'SUNDAY' + @dataclass class TimeWindowDay: + """Represents the time window schedule for a single day of the week. + + Attributes: + id (int): Unique identifier for the day entry. + weekday (Weekday): The day of the week. + phases (list[TimeWindowPhase]): List of time phases for this day. + + """ + id: int weekday: Weekday phases: list['TimeWindowPhase'] @classmethod - def from_dict(cls, obj: dict) -> 'TimeWindowDay': - _id = obj["id"] - weekday = Weekday(obj["weekDay"]) - phases = TimeWindowPhase.from_list(obj["phases"]) + def _from_dict(cls, obj: dict) -> 'TimeWindowDay': + _id = obj['id'] + weekday = Weekday(obj['weekDay']) + phases = TimeWindowPhase._from_list(obj['phases']) return cls(_id, weekday, phases) @classmethod - def from_list(cls, obj: list) -> list['TimeWindowDay']: - return [cls.from_dict(i) for i in obj] + def _from_list(cls, obj: list) -> list['TimeWindowDay']: + return [cls._from_dict(i) for i in obj] + @dataclass class TimeWindowPhase: + """Represents a time phase within a single day. + + Attributes: + start_hour (int): Hour when the phase starts (0–23). + start_minute (int): Minute when the phase starts (0–59). + end_hour (int): Hour when the phase ends (0–23). + end_minute (int): Minute when the phase ends (0–59). + + """ + start_hour: int start_minute: int end_hour: int end_minute: int @classmethod - def from_dict(cls, obj: dict) -> 'TimeWindowPhase': + def _from_dict(cls, obj: dict) -> 'TimeWindowPhase': sh = obj['startHour'] sm = obj['startMinute'] eh = obj['endHour'] @@ -61,5 +97,5 @@ def from_dict(cls, obj: dict) -> 'TimeWindowPhase': return cls(sh, sm, eh, em) @classmethod - def from_list(cls, obj: list) -> list['TimeWindowPhase']: - return [cls.from_dict(i) for i in obj] + def _from_list(cls, obj: list) -> list['TimeWindowPhase']: + return [cls._from_dict(i) for i in obj] diff --git a/src/froeling/datamodels/notifications.py b/src/froeling/datamodels/notifications.py index f1826e8..95a05f8 100644 --- a/src/froeling/datamodels/notifications.py +++ b/src/froeling/datamodels/notifications.py @@ -1,101 +1,130 @@ +"""Datamodels to represent Notifications and related objects.""" + from dataclasses import dataclass import datetime from .. import endpoints +from ..session import Session + class NotificationOverview: """Stores basic data of a notification.""" - id: int - subject: str - unread: bool - date: datetime.date - error_id: int - type: str + + id: int | None + subject: str | None + unread: bool | None + date: datetime.date | None + error_id: int | None + type: str | None """Known Values: "ERROR", "INFO", "WARNING", "ALARM" """ - facility_id: int - facility_name: str + facility_id: int | None + facility_name: str | None details: 'NotificationDetails' - def __init__(self, data, session): + def __init__(self, data: dict, session: 'Session') -> None: + """Create a new NotificationOverview.""" self.session = session - self.set_data(data) + self._set_data(data) - def set_data(self, data): + def _set_data(self, data: dict) -> None: self.data = data self.id = data.get('id') self.subject = data.get('subject') self.unread = data.get('unread') - self.date = datetime.datetime.fromisoformat(data.get('notificationDate')) + date_str = data.get('notificationDate') + if isinstance(date_str, str): + self.date = datetime.datetime.fromisoformat(date_str) + else: + self.date = None self.error_id = data.get('errorId') self.type = data.get('notificationType') self.facility_id = data.get('facilityId') self.facility_name = data.get('facilityName') - """Gets additional information about this notification.""" - async def info(self): - res = await self.session.request("get", endpoints.NOTIFICATION.format(self.session.user_id, self.id)) - self.details = NotificationDetails.from_dict(res) + async def info(self) -> 'NotificationDetails': + """Get additional information about this notification.""" + res = await self.session.request( + 'get', endpoints.NOTIFICATION.format(self.session.user_id, self.id) + ) + self.details = NotificationDetails._from_dict(res) return self.details @dataclass class NotificationDetails(NotificationOverview): - """Stores all data of a notification""" - body: str - sms: bool - mail: bool - push: bool - notification_submission_state_dto: list['NotificationSubmissionState'] - errorSolutions: list['NotificationErrorSolutions'] + """Stores all data related to a notification.""" + + body: str | None + sms: bool | None + mail: bool | None + push: bool | None + notification_submission_state_dto: list['NotificationSubmissionState'] | None + error_solutions: list['NotificationErrorSolution'] | None @classmethod - def from_dict(cls, obj): - body = obj.get("body") - sms = obj.get("sms") - mail = obj.get("mail") - push = obj.get("push") + def _from_dict(cls, obj: dict) -> 'NotificationDetails': + body = obj.get('body') + sms = obj.get('sms') + mail = obj.get('mail') + push = obj.get('push') submission_state = None - if "notificationSubmissionStateDto" in obj: - submission_state = NotificationSubmissionState.from_list(obj["notificationSubmissionStateDto"]) + if 'notificationSubmissionStateDto' in obj: + submission_state = NotificationSubmissionState._from_list( + obj['notificationSubmissionStateDto'] + ) error_solutions = None - if "errorSolutions" in obj: - error_solutions = NotificationErrorSolutions.from_list(obj["errorSolutions"]) - notificationDetailsObject = cls(body, sms, mail, push, submission_state, error_solutions) - notificationDetailsObject.set_data(obj) - return notificationDetailsObject + if 'errorSolutions' in obj: + error_solutions = NotificationErrorSolution._from_list( + obj['errorSolutions'] + ) + notification_details_object = cls( + body, sms, mail, push, submission_state, error_solutions + ) + notification_details_object._set_data(obj) + return notification_details_object + @dataclass class NotificationSubmissionState: - id: int - recipient: str - type: str - submitted_to: str - submission_result: str + """Submission state of a notification.""" + id: int | None + recipient: str | None + type: str | None + submitted_to: str | None + submission_result: str | None @classmethod - def from_dict(cls, obj: dict) -> 'NotificationSubmissionState': - _id = obj.get('id') + def _from_dict(cls, obj: dict) -> 'NotificationSubmissionState': + notification_id = obj.get('id') recipient = obj.get('recipient') - type = obj.get('type') + notification_type = obj.get('type') """Known values: "EMAIL", "TOKEN" """ submitted_to = obj.get('submittedTo') submission_result = obj.get('submissionResult') - return NotificationSubmissionState(_id, recipient, type, submitted_to, submission_result) + return NotificationSubmissionState( + notification_id, + recipient, + notification_type, + submitted_to, + submission_result, + ) @classmethod - def from_list(cls, obj: list[dict]): - return [cls.from_dict(i) for i in obj] + def _from_list(cls, obj: list[dict]) -> list['NotificationSubmissionState']: + return [cls._from_dict(i) for i in obj] @dataclass -class NotificationErrorSolutions: - error_reason: str - error_solution: str +class NotificationErrorSolution: + """Reasons for why the error might occur and steps to take to resolve it.""" + + error_reason: str | None + error_solution: str | None @classmethod - def from_list(cls, obj: list[dict]) -> list['NotificationErrorSolutions']: - return [cls(i['errorReason'], i['errorSolution']) for i in obj] \ No newline at end of file + def _from_list(cls, obj: list[dict]) -> list['NotificationErrorSolution']: + return [cls(i['errorReason'], i['errorSolution']) for i in obj] diff --git a/src/froeling/datamodels/userdata.py b/src/froeling/datamodels/userdata.py index af8b1bc..302d5ea 100644 --- a/src/froeling/datamodels/userdata.py +++ b/src/froeling/datamodels/userdata.py @@ -1,37 +1,53 @@ -import typing +"""Datamodels related to the user account.""" + from dataclasses import dataclass from .generics import Address @dataclass(frozen=True) class UserData: - email: str - salutation: str - firstname: str - surname: str - address: typing.Optional['Address'] + """Data relating to the user account.""" + + email: str | None + salutation: str | None + firstname: str | None + surname: str | None + address: 'Address' | None user_id: int - lang: str - role: str - active: bool - pictureUrl: str - facilityCount: int + lang: str | None + role: str | None + active: bool | None + picture_url: str | None + facility_count: int | None @staticmethod - def from_dict(obj: dict): - email = obj['userData'].get("email") - salutation = obj['userData'].get("salutation") - firstname = obj['userData'].get("firstname") - surname = obj['userData'].get("surname") - if obj['userData'].get("address"): - address = Address.from_dict(obj['userData'].get("address")) - else: - address = None - user_id = obj['userData'].get("userId") - lang = obj.get("lang") - role = obj.get("role") - active = obj.get("active") - pictureUrl = obj.get("pictureUrl") - facilityCount = obj.get("facilityCount") - return UserData(email, salutation, firstname, surname, address, user_id, lang, role, active, pictureUrl, - facilityCount) + def _from_dict(obj: dict) -> 'UserData': + user_data = obj['userData'] + email: str | None = user_data.get('email') + salutation: str | None = user_data.get('salutation') + firstname: str | None = user_data.get('firstname') + surname: str | None = user_data.get('surname') + + address: 'Address' | None = ( + Address._from_dict(user_data['address']) if 'address' in user_data else None + ) + + user_id: int = user_data.get('userId', -1) + lang: str | None = obj.get('lang') + role: str | None = obj.get('role') + active: bool | None = obj.get('active') + picture_url: str | None = obj.get('pictureUrl') + facility_count: int | None = obj.get('facilityCount') + return UserData( + email, + salutation, + firstname, + surname, + address, + user_id, + lang, + role, + active, + picture_url, + facility_count, + ) diff --git a/src/froeling/endpoints.py b/src/froeling/endpoints.py index d2c06b7..57dc383 100644 --- a/src/froeling/endpoints.py +++ b/src/froeling/endpoints.py @@ -1,4 +1,4 @@ -"""API Endpoints""" +"""List of static API Endpoints.""" LOGIN = 'https://connect-api.froeling.com/connect/v1.0/resources/login' """post data: {"username": username, "password": password}""" @@ -6,11 +6,15 @@ USER = 'https://connect-api.froeling.com/connect/v1.0/resources/service/user/{}' """1: user_id""" -FACILITY = 'https://connect-api.froeling.com/connect/v1.0/resources/service/user/{}/facility' +FACILITY = ( + 'https://connect-api.froeling.com/connect/v1.0/resources/service/user/{}/facility' +) """1: user_id facility_ids = res[*]["facilityId"]""" -OVERVIEW = 'https://connect-api.froeling.com/fcs/v1.0/resources/user/{}/facility/{}/overview' +OVERVIEW = ( + 'https://connect-api.froeling.com/fcs/v1.0/resources/user/{}/facility/{}/overview' +) """1: user_id 2: facility_id""" COMPONENT_LIST = 'https://connect-api.froeling.com/fcs/v1.0/resources/user/{}/facility/{}/componentList' diff --git a/src/froeling/exceptions.py b/src/froeling/exceptions.py index a8e0c74..be21564 100644 --- a/src/froeling/exceptions.py +++ b/src/froeling/exceptions.py @@ -1,20 +1,63 @@ +"""Exceptions mostly relating to web requests.""" + +from aiohttp.typedefs import StrOrURL + + class AuthenticationError(BaseException): + """Raised for unauthorized requests, failed reauths, or bad credentials.""" + pass class NetworkError(BaseException): - def __init__(self, msg, status, url, res): + """Raised on unsuccessful HTTP status codes.""" + + def __init__(self, msg: str, status: int, url: StrOrURL, res: str) -> None: + """Initialize a NetworkError. + + Args: + msg (str): Short description of the error. + status (int): HTTP status code returned by the request. + url (StrOrURL): The requested URL. + res (str): Raw response body returned by the server. + + """ super().__init__(f'{msg}: Status: {status}, url: {url}\nResult: {res}') self.status = status self.url = url class ParsingError(BaseException): - def __init__(self, msg, doc, pos, url): + """Raised when parsing an API response fails. + + Attributes: + msg (str): Short description of the parsing error. + doc (str): The raw response text that failed to parse. + pos (int): The character position in `doc` where the error occurred. + url (StrOrURL): The URL that was requested. + lineno (int): Line number of the error position. + colno (int): Column number of the error position. + + """ + + def __init__(self, msg: str, doc: str, pos: int, url: StrOrURL) -> None: + """Initialize a ParsingError. + + Args: + msg (str): Description of the parsing error. + doc (str): The raw response text. + pos (int): Character position in `doc` where the error occurred. + url (StrOrURL): The URL that was requested. + + """ lineno = doc.count('\n', 0, pos) + 1 colno = pos - doc.rfind('\n', 0, pos) - errmsg = 'Error while parsing API response while fetching %s. %s: line %d column %d (char %d)\n%s' % (url, msg, lineno, colno, pos, doc) - super().__init__(self, errmsg) + errmsg = ( + f'Error while parsing API response while fetching {url}. {msg}: ' + f'line {lineno} column {colno} (char {pos})\n{doc}' + ) + + super().__init__(errmsg) self.msg = msg self.doc = doc self.pos = pos diff --git a/src/froeling/session.py b/src/froeling/session.py index 19e08b3..f1ec93c 100644 --- a/src/froeling/session.py +++ b/src/froeling/session.py @@ -1,6 +1,8 @@ -"""Manages authentication and error handling""" +"""Manages authentication, requests and error handling.""" +from typing import Callable, Any from aiohttp import ClientSession +from aiohttp.typedefs import StrOrURL import json import base64 import logging @@ -8,81 +10,135 @@ from . import endpoints, exceptions -#headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36', 'Referer': 'https://connect-web.froeling.com/'} -# Seems like user-agent and referer are not required - class Session: - user_id: int = None - token: str = None - def __init__(self, - username: str=None, password: str=None, token: str=None, - auto_reauth: bool=False, - token_callback=None, - lang: str = 'en', - logger: logging.Logger=None, - clientsession: ClientSession=None - ): - assert token or (username and password), "Set either token or username and password." - assert not (auto_reauth and not (username and password)), "Set username and password to use auto_reauth." + """Represents an authenticated session with the API. + + A `Session` manages authentication, tokens, and connection details + required to communicate with the backend. It can be created with + either a username/password combination or an existing token. + Both user_id and token are set if the user is logged in. + + Attributes: + user_id (int | None): ID of the authenticated user. + token (str | None): Active authentication token for this session. + + """ + + user_id: int | None = None + token: str | None = None + + def __init__( + self, + username: str | None = None, + password: str | None = None, + token: str | None = None, + auto_reauth: bool = False, + token_callback: Callable[[str], Any] | None = None, + lang: str = 'en', + logger: logging.Logger | None = None, + clientsession: ClientSession | None = None, + ) -> None: + """Initialize a new Session. + + You can authenticate by providing a `username` and `password`, + or by supplying an existing `token`. When `auto_reauth` is enabled, + the session will attempt to automatically refresh expired tokens. + + Args: + username (str | None): Username for authentication. + password (str | None): Password for authentication. + token (str | None): Existing authentication token. + auto_reauth (bool): Whether to automatically refresh tokens + when they expire. Defaults to False. + token_callback (Callable[[str], Any] | None): Optional function + that is called whenever a new token is obtained. + lang (str): Preferred language for API responses. Defaults to `'en'`. + logger (logging.Logger | None): Logger instance for debugging and events. + clientsession (ClientSession | None): Optional aiohttp + client session to reuse instead of creating a new one. + + """ + assert token or (username and password), ( + 'Set either token or username and password.' + ) + assert not (auto_reauth and not (username and password)), ( + 'Set username and password to use auto_reauth.' + ) self.clientsession = clientsession or ClientSession() - self._headers = {'Accept-Language': lang } + self._headers = {'Accept-Language': lang} self.username = username self.password = password self.auto_reauth = auto_reauth self.token_callback = token_callback - if token: self.set_token(token) self._logger = logger or logging.getLogger(__name__) - self._reauth_previous = False # Did the previous request result in renewing the token? + self._reauth_previous = ( + False # Did the previous request result in renewing the token? + ) - async def close(self): + async def close(self) -> None: + """Close the session.""" await self.clientsession.close() - def set_token(self, token: str): - """Sets the token used in Authorization and updates/sets user-id - :param token The bearer token""" + def set_token(self, token: str) -> None: + """Set the token used in Authorization and updates/sets user-id. + + :param token The bearer token + """ self._headers['Authorization'] = token try: - self.user_id = json.loads(base64.b64decode(token.split('.')[1] + "==").decode("utf-8"))['userId'] - except: - raise ValueError("Token is in an invalid format.") - if self.token_callback and self.token: # Only run when overwriting existing token + self.user_id = json.loads( + base64.b64decode(token.split('.')[1] + '==').decode('utf-8') + )['userId'] + except Exception as e: + raise ValueError('Token is in an invalid format.') from e + if ( + self.token_callback and self.token + ): # Only run when overwriting existing token self.token = token self.token_callback(token) else: self.token = token async def login(self) -> dict: - """Gets a token using username and password - :return: Json sent by server (includes userdata)""" + """Get a token using username and password. + + :return: Json sent by server (includes userdata) + """ data = {'osType': 'web', 'username': self.username, 'password': self.password} async with await self.clientsession.post(endpoints.LOGIN, json=data) as res: if res.status != 200: - raise exceptions.AuthenticationError(f'Server returned {res.status}: "{await res.text()}"') + raise exceptions.AuthenticationError( + f'Server returned {res.status}: "{await res.text()}"' + ) self.set_token(res.headers['Authorization']) userdata = await res.json() - self._logger.debug("Logged in with username and password.") + self._logger.debug('Logged in with username and password.') return userdata - async def request(self, method, url, headers=None, **kwargs): - """ + async def request( + self, method: str, url: StrOrURL, headers: dict | None = None, **kwargs: Any + ) -> Any: + """Do a web request. :param method: :param url: :param headers: Additional headers used in the request :param kwargs: """ - self._logger.debug(f'Sent %s: %s', method.upper(), url) + self._logger.debug('Sent %s: %s', method.upper(), url) request_headers = self._headers if headers: request_headers |= headers try: - async with await self.clientsession.request(method, url, headers=request_headers, **kwargs) as res: + async with await self.clientsession.request( + method, url, headers=request_headers, **kwargs + ) as res: if 299 >= res.status >= 200: r = await res.text() self._logger.debug('Got %s', r) @@ -92,18 +148,29 @@ async def request(self, method, url, headers=None, **kwargs): if res.status == 401: if self.auto_reauth: if self._reauth_previous: - raise exceptions.AuthenticationError("Reauth did not work.", await res.text()) - self._logger.info('Error %s, renewing token...', await res.text()) + raise exceptions.AuthenticationError( + 'Reauth did not work.', await res.text() + ) + self._logger.info( + 'Error %s, renewing token...', await res.text() + ) await self.login() self._logger.info('Reauthorized.') self._reauth_previous = True return await self.request(method, url, **kwargs) else: - self._logger.error("Request unauthorized") - raise exceptions.AuthenticationError("Request not authorized: ", await res.text()) + self._logger.error('Request unauthorized') + raise exceptions.AuthenticationError( + 'Request not authorized: ', await res.text() + ) else: error_data = await res.text() - raise exceptions.NetworkError("Unexpected return code", status=res.status, url=res.url, res=error_data) + raise exceptions.NetworkError( + 'Unexpected return code', + status=res.status, + url=res.url, + res=error_data, + ) except json.decoder.JSONDecodeError as e: raise exceptions.ParsingError(e.msg, e.doc, e.pos, url) From b3f7855d61aa9297992f87f7c46a9701c77d76fe Mon Sep 17 00:00:00 2001 From: Layf <93056040+Layf21@users.noreply.github.com> Date: Wed, 10 Sep 2025 17:38:50 +0200 Subject: [PATCH 05/12] Fix broken typehints --- src/froeling/datamodels/component.py | 2 +- src/froeling/datamodels/userdata.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/froeling/datamodels/component.py b/src/froeling/datamodels/component.py index ba20446..2165034 100644 --- a/src/froeling/datamodels/component.py +++ b/src/froeling/datamodels/component.py @@ -57,7 +57,7 @@ def __init__(self, facility_id: int, component_id: str, session: Session): @classmethod def _from_overview_data( cls, facility_id: int, session: Session, obj: dict - ) -> 'Component' | None: + ) -> 'Component | None': """Create a new component and populate it with overview data.""" component_id = obj.get('componentId') if not isinstance(component_id, str): diff --git a/src/froeling/datamodels/userdata.py b/src/froeling/datamodels/userdata.py index 302d5ea..6586cd8 100644 --- a/src/froeling/datamodels/userdata.py +++ b/src/froeling/datamodels/userdata.py @@ -12,7 +12,7 @@ class UserData: salutation: str | None firstname: str | None surname: str | None - address: 'Address' | None + address: Address | None user_id: int lang: str | None role: str | None @@ -28,7 +28,7 @@ def _from_dict(obj: dict) -> 'UserData': firstname: str | None = user_data.get('firstname') surname: str | None = user_data.get('surname') - address: 'Address' | None = ( + address: Address | None = ( Address._from_dict(user_data['address']) if 'address' in user_data else None ) From ff05769ae5bd80da838f3b2506d5b3270b6e6d65 Mon Sep 17 00:00:00 2001 From: Layf <93056040+Layf21@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:06:05 +0200 Subject: [PATCH 06/12] Added tests Established consistent linter and typechecking settings by switching to hatch Breaking change: Component.parameter is now a dict instead of a list. --- .pre-commit-config.yaml | 22 +- example.py | 68 ++-- pyproject.toml | 42 ++- src/froeling/__init__.py | 22 +- src/froeling/client.py | 94 +++-- src/froeling/datamodels/__init__.py | 8 +- src/froeling/datamodels/component.py | 51 ++- src/froeling/datamodels/facility.py | 25 +- src/froeling/datamodels/generics.py | 28 +- src/froeling/datamodels/notifications.py | 29 +- src/froeling/datamodels/userdata.py | 7 +- src/froeling/endpoints.py | 8 +- src/froeling/exceptions.py | 19 +- src/froeling/session.py | 110 +++--- tests/conftest.py | 16 + tests/responses/component.json | 451 +++++++++++++++++++++++ tests/responses/component_list.json | 45 +++ tests/responses/facility.json | 51 +++ tests/responses/facility_modified.json | 58 +++ tests/responses/login.json | 29 ++ tests/responses/login_bad_creds.json | 4 + tests/responses/notification.json | 26 ++ tests/responses/notification_count.json | 3 + tests/responses/notification_list.json | 23 ++ tests/responses/overview.json | 403 ++++++++++++++++++++ tests/responses/user.json | 30 ++ tests/test_components.py | 89 +++++ tests/test_facility.py | 252 +++++++++++++ tests/test_login.py | 85 +++++ tests/test_notifications.py | 125 +++++++ 30 files changed, 1965 insertions(+), 258 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/responses/component.json create mode 100644 tests/responses/component_list.json create mode 100644 tests/responses/facility.json create mode 100644 tests/responses/facility_modified.json create mode 100644 tests/responses/login.json create mode 100644 tests/responses/login_bad_creds.json create mode 100644 tests/responses/notification.json create mode 100644 tests/responses/notification_count.json create mode 100644 tests/responses/notification_list.json create mode 100644 tests/responses/overview.json create mode 100644 tests/responses/user.json create mode 100644 tests/test_components.py create mode 100644 tests/test_facility.py create mode 100644 tests/test_login.py create mode 100644 tests/test_notifications.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 54adf8a..9af9538 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,12 +10,20 @@ repos: - id: check-merge-conflict - id: trailing-whitespace - id: end-of-file-fixer -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.12 - hooks: - - id: ruff-format - - id: ruff-check -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.17.1 +- repo: local hooks: + - id: format + name: format + entry: hatch fmt + language: system + pass_filenames: false - id: mypy + name: mypy + entry: hatch run dev:mypy src + language: system + pass_filenames: false + - id: test + name: test + entry: hatch test + language: system + pass_filenames: false diff --git a/example.py b/example.py index 39de498..3633e95 100644 --- a/example.py +++ b/example.py @@ -6,57 +6,71 @@ from froeling import Froeling load_dotenv() -username = os.getenv("USERNAME") -password = os.getenv("PASSWORD") -token = os.getenv("TOKEN") +username = os.getenv('USERNAME') +password = os.getenv('PASSWORD') +token = os.getenv('TOKEN') if not (username and password): username = input('Username (E-Mail): ') password = input('Password : ') -def print_new_token(token): # Gets executed when a new token was created (useful for storing the token for next time the program is run) +def print_new_token( + token, +): # Gets executed when a new token was created (useful for storing the token for next time the program is run) print('The new token ist:', token) - async def main(): # You can use an external logger logging.basicConfig(level=logging.INFO) # When only using token, auto reauth is not available - async with Froeling(username, password, token=token, auto_reauth=True, language='en', token_callback=print_new_token) as client: - - for notification in await client.get_notifications(): # Fetch notifications - await notification.info() # Load more information about one of the notifications - print(f'\n[Notification {notification.id}] Subject: {notification.subject}\n{notification.details.body}\n\n') - + async with Froeling( + username, + password, + token=token, + auto_reauth=True, + language='en', + token_callback=print_new_token, + ) as client: + for notification in (await client.get_notifications())[:3]: # Fetch notifications + await ( + notification.info() + ) # Load more information about one of the notifications + print( + f'\n[Notification {notification.id}] Subject: {notification.subject}\n{notification.details.body}\n\n' + ) facility = (await client.get_facilities())[0] # Get a list of all facilities print(facility) - example_component = (await facility.get_components())[0] # Get all components of the facility + # Get all components of the facility + example_component = (await facility.get_components())[0] print(example_component) - await example_component.update() # Get more information about the component. This includes the parameters. + await ( + example_component.update() + ) # Get more information about the component. This includes the parameters. print(f'{example_component.type} {example_component.sub_type}: {example_component.display_name} \n{"_" * 20}') - for i in example_component.parameters: # Loop over all data af the component - print(i.display_name, ":", i.display_value) - + for parameter in ( + example_component.parameters.values() + ): # Loop over all data af the component + print(parameter.display_name, ':', parameter.display_value) # You can directly reference a component of a facility by its id - example_component2 = facility.get_component("1_100") - await example_component2.update() - print("\n\nExample Component:", example_component2.display_name) - - for param in example_component2.parameters: - if param.id == "7_28": - print(f"Setting {param.display_name} to 80") - # await param.set_value(80) + example_component2 = facility.get_component('1_100') + await example_component2.update() # The update method is required to fully populete the component's data. + print('\n\nExample Component:', example_component2.display_name) + param = example_component2.parameters.get('7_28') + if param: + print(f'Value was {param.display_value}') + print(f'Setting {param.display_name} to 80') + # await param.set_value(80) # This changes a live system parameter. Uncomment only if you understand the effect. - # If you know the facilityId and component_id, you can get the component like this. - client.get_component(facility.facilityId, "1_100") + # If you know the facilityId and component_id, you can get the component like this. The data won't be populated. + client.get_component(facility.facility_id, '1_100') -asyncio.get_event_loop().run_until_complete(main()) +asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 027d8f2..c5e79d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,9 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - [project] name = "froeling-connect" version = "0.1.3" description = "A python wrapper for the Fröling-Connect API" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = {file = "LICENSE.txt"} keywords = ["froeling", "fröling", "fröling-connect", "fröling connect"] authors = [ @@ -44,32 +40,39 @@ docs = "https://github.com/Layf21/froeling-connect/blob/master/README.md" issues = "https://github.com/Layf21/froeling-connect/issues" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +exclude = [ + "/.github" +] + [tool.hatch.build.targets.wheel] packages = ["src/froeling"] + [tool.hatch.envs.dev] dependencies = [ + "python-dotenv", "pre-commit", "mypy", "ruff", "pytest", + "pytest-asyncio", + "aioresponses", + "coverage", ] - [tool.ruff] include = ["src/**/*.py"] -lint.select = ["D", "E", "F", "N", "Q"] [tool.ruff.format] quote-style = "single" -indent-style = "space" - -[tool.ruff.lint.flake8-quotes] -inline-quotes = "single" -multiline-quotes = "double" -docstring-quotes = "double" -avoid-escape = true +[tool.ruff.lint.pydocstyle] +convention = "google" [tool.mypy] python_version = "3.10" @@ -82,3 +85,14 @@ ignore_missing_imports = true strict_optional = true warn_redundant_casts = true allow_untyped_globals = false + + +[tool.hatch.envs.hatch-test] +randomize = true +extra-dependencies = [ + "aioresponses", + "pytest-asyncio", +] + +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.13", "3.12", "3.11", "3.10"] diff --git a/src/froeling/__init__.py b/src/froeling/__init__.py index e9f5f30..0d06f6a 100644 --- a/src/froeling/__init__.py +++ b/src/froeling/__init__.py @@ -6,24 +6,26 @@ Github and documentation: https://https://github.com/Layf21/froeling-connect.py """ -from .client import Froeling -from .datamodels import ( - Facility, +from froeling.client import Froeling +from froeling.datamodels import ( + Address, Component, + Facility, + NotificationDetails, + NotificationOverview, Parameter, UserData, - NotificationOverview, - NotificationDetails, - Address, ) +from froeling.session import Session __all__ = [ 'Froeling', - 'Facility', + 'Session', + 'Address', 'Component', + 'Facility', + 'NotificationDetails', + 'NotificationOverview', 'Parameter', 'UserData', - 'NotificationOverview', - 'NotificationDetails', - 'Address', ] diff --git a/src/froeling/client.py b/src/froeling/client.py index c04fa33..bf55d64 100644 --- a/src/froeling/client.py +++ b/src/froeling/client.py @@ -1,34 +1,36 @@ """Provides the main API Class.""" -from types import TracebackType -from typing import Optional, Type, Callable, Any import logging +from collections.abc import Callable +from types import TracebackType +from typing import Any + from aiohttp import ClientSession -from . import datamodels -from . import endpoints -from .session import Session +from froeling import datamodels, endpoints +from froeling.exceptions import FacilityNotFoundError +from froeling.session import Session class Froeling: """The Froeling class provides access to the Fröling API.""" - # cached data (does not change often) - _userdata: datamodels.UserData - _facilities: dict[int, datamodels.Facility] = {} - async def __aenter__(self) -> 'Froeling': """Create an API session.""" - if not self.session.token: - await self.login() + try: + if not self.session.token: + await self.login() + except Exception: + await self.session.close() + raise return self async def __aexit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> Optional[bool]: + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: """End an API session.""" await self.session.close() return None @@ -38,6 +40,7 @@ def __init__( username: str | None = None, password: str | None = None, token: str | None = None, + *, auto_reauth: bool = False, token_callback: Callable[[str], Any] | None = None, language: str = 'en', @@ -49,6 +52,7 @@ def __init__( Either `username` and `password` or a `token` is required. Args: + ---- username (str | None): Email used to log into your Fröling account. password (str | None): Fröling password (not required when using `token`). token (str | None): Valid token (not required when using username/password). @@ -63,22 +67,26 @@ def __init__( instead of creating a new one. Defaults to None. """ + # cached data (does not change often) + self._userdata: datamodels.UserData | None = None + self._facilities: dict[int, datamodels.Facility] = {} + self.session = Session( username, password, token, - auto_reauth, - token_callback, - language, - logger, - clientsession, + auto_reauth=auto_reauth, + token_callback=token_callback, + lang=language, + logger=logger, + clientsession=clientsession, ) self._logger = logger or logging.getLogger(__name__) async def login(self) -> datamodels.UserData: """Log in with the username and password.""" data = await self.session.login() - self._userdata = datamodels.UserData._from_dict(data) + self._userdata = datamodels.UserData._from_dict(data) # noqa: SLF001 return self._userdata async def close(self) -> None: @@ -97,10 +105,8 @@ def token(self) -> str | None: async def _get_userdata(self) -> datamodels.UserData: """Fetch userdata (cached).""" - res = await self.session.request( - 'get', endpoints.USER.format(self.session.user_id) - ) - return datamodels.UserData._from_dict(res) + res = await self.session.request('get', endpoints.USER.format(self.session.user_id)) + return datamodels.UserData._from_dict(res) # noqa: SLF001 async def get_userdata(self) -> datamodels.UserData: """Get userdata (cached).""" @@ -110,10 +116,8 @@ async def get_userdata(self) -> datamodels.UserData: async def _get_facilities(self) -> list[datamodels.Facility]: """Fetch all facilities connected with the account and cache them.""" - res = await self.session.request( - 'get', endpoints.FACILITY.format(self.session.user_id) - ) - return datamodels.Facility._from_list(res, self.session) + res = await self.session.request('get', endpoints.FACILITY.format(self.session.user_id)) + return datamodels.Facility._from_list(res, self.session) # noqa: SLF001 async def get_facilities(self) -> list[datamodels.Facility]: """Get all cacilities connected with this account (cached).""" @@ -126,38 +130,28 @@ async def get_facility(self, facility_id: int) -> datamodels.Facility: """Get a specific facility given it's id (cached).""" if facility_id not in self._facilities: await self.get_facilities() - assert facility_id in self._facilities, ( - f'Facility with id {facility_id} not found.' - ) + + if facility_id not in self._facilities: + raise FacilityNotFoundError(facility_id) return self._facilities[facility_id] async def get_notification_count(self) -> int: """Fetch the unread notification count.""" - return ( - await self.session.request( - 'get', endpoints.NOTIFICATION_COUNT.format(self.session.user_id) - ) - )['unreadNotifications'] + return (await self.session.request('get', endpoints.NOTIFICATION_COUNT.format(self.session.user_id)))[ + 'unreadNotifications' + ] async def get_notifications(self) -> list[datamodels.NotificationOverview]: """Fetch an overview of all notifications.""" - res = await self.session.request( - 'get', endpoints.NOTIFICATION_LIST.format(self.session.user_id) - ) + res = await self.session.request('get', endpoints.NOTIFICATION_LIST.format(self.session.user_id)) return [datamodels.NotificationOverview(n, self.session) for n in res] - async def get_notification( - self, notification_id: int - ) -> datamodels.NotificationDetails: + async def get_notification(self, notification_id: int) -> datamodels.NotificationDetails: """Fetch all details for a specific notification.""" - res = await self.session.request( - 'get', endpoints.NOTIFICATION.format(self.session.user_id, notification_id) - ) - return datamodels.NotificationDetails._from_dict(res) + res = await self.session.request('get', endpoints.NOTIFICATION.format(self.session.user_id, notification_id)) + return datamodels.NotificationDetails._from_dict(res) # noqa: SLF001 - def get_component( - self, facility_id: int, component_id: str - ) -> datamodels.Component: + def get_component(self, facility_id: int, component_id: str) -> datamodels.Component: """Get a specific component given it's facility_id and component_id. Call the update method for this component to populate it's attributes. diff --git a/src/froeling/datamodels/__init__.py b/src/froeling/datamodels/__init__.py index 73d356e..21e863f 100644 --- a/src/froeling/datamodels/__init__.py +++ b/src/froeling/datamodels/__init__.py @@ -1,9 +1,9 @@ """Datamodels to represent the API objects in python.""" -from .userdata import UserData, Address -from .notifications import NotificationOverview, NotificationDetails -from .facility import Facility -from .component import Component, Parameter +from froeling.datamodels.component import Component, Parameter +from froeling.datamodels.facility import Facility +from froeling.datamodels.notifications import NotificationDetails, NotificationOverview +from froeling.datamodels.userdata import Address, UserData __all__ = [ 'UserData', diff --git a/src/froeling/datamodels/component.py b/src/froeling/datamodels/component.py index 2165034..bbb6b11 100644 --- a/src/froeling/datamodels/component.py +++ b/src/froeling/datamodels/component.py @@ -1,12 +1,13 @@ """Represents Components and their Parameters.""" -from typing import Any from dataclasses import dataclass +from http import HTTPStatus +from typing import Any -from .. import endpoints -from ..session import Session -from ..exceptions import NetworkError -from .generics import TimeWindowDay +from froeling import endpoints +from froeling.datamodels.generics import TimeWindowDay +from froeling.exceptions import NetworkError +from froeling.session import Session class Component: @@ -46,7 +47,7 @@ class Component: time_windows_view: list[TimeWindowDay] | None picture_url: str | None - parameters: list['Parameter'] + parameters: dict[str, 'Parameter'] def __init__(self, facility_id: int, component_id: str, session: Session): """Initialize a Component with minimal identifying information.""" @@ -54,10 +55,12 @@ def __init__(self, facility_id: int, component_id: str, session: Session): self.component_id = component_id self._session = session + self.time_windows_view = None + self.picture_url = None + self.parameters = {} + @classmethod - def _from_overview_data( - cls, facility_id: int, session: Session, obj: dict - ) -> 'Component | None': + def _from_overview_data(cls, facility_id: int, session: Session, obj: dict) -> 'Component | None': """Create a new component and populate it with overview data.""" component_id = obj.get('componentId') if not isinstance(component_id, str): @@ -75,13 +78,11 @@ def __str__(self) -> str: """Return a string representation of this component.""" return f'Component([Facility {self.facility_id}] -> {self.component_id})' - async def update(self) -> list['Parameter']: + async def update(self) -> dict[str, 'Parameter']: """Update the Parameters of this component.""" res = await self._session.request( 'get', - endpoints.COMPONENT.format( - self._session.user_id, self.facility_id, self.component_id - ), + endpoints.COMPONENT.format(self._session.user_id, self.facility_id, self.component_id), ) self.component_id = res.get('componentId') # This should not be able to change. self.display_name = res.get('displayName') @@ -90,12 +91,12 @@ async def update(self) -> list['Parameter']: self.type = res.get('type') self.sub_type = res.get('subType') if res.get('timeWindowsView'): - self.time_windows_view = TimeWindowDay._from_list(res['timeWindowsView']) + self.time_windows_view = TimeWindowDay._from_list(res['timeWindowsView']) # noqa: SLF001 # TODO: Find endpoint that gives all parameters topview = res.get('topView') - parameters: dict[str, dict] = dict() + parameters: dict[str, dict] = {} if topview: self.picture_url = topview.get('pictureUrl') if 'pictureParams' in topview: @@ -109,9 +110,7 @@ async def update(self) -> list['Parameter']: if 'setupView' in res: parameters |= {i['name']: i for i in res.get('setupView')} - self.parameters = Parameter._from_list( - list(parameters.values()), self._session, self.facility_id - ) + self.parameters = Parameter._from_list(list(parameters.values()), self._session, self.facility_id) # noqa: SLF001 return self.parameters @@ -122,7 +121,7 @@ class Parameter: session: Session facility_id: int - id: str | None + id: str display_name: str | None name: str | None editable: bool | None @@ -162,11 +161,9 @@ def _from_dict(cls, obj: dict, session: Session, facility_id: int) -> 'Parameter ) @classmethod - def _from_list( - cls, obj: list[dict], session: Session, facility_id: int - ) -> list['Parameter']: + def _from_list(cls, obj: list[dict], session: Session, facility_id: int) -> dict[str, 'Parameter']: """Turn a list of api response dicts into a list of parameter object.""" - return [cls._from_dict(i, session, facility_id) for i in obj] + return {p.id: p for p in (cls._from_dict(i, session, facility_id) for i in obj)} @property def display_value(self) -> str: @@ -187,12 +184,10 @@ async def set_value(self, value: Any) -> Any | None: try: return await self.session.request( 'put', - endpoints.SET_PARAMETER.format( - self.session.user_id, self.facility_id, self.id - ), + endpoints.SET_PARAMETER.format(self.session.user_id, self.facility_id, self.id), json={'value': str(value)}, ) except NetworkError as e: - if e.status == 304: # unchanged + if e.status == HTTPStatus.NOT_MODIFIED: return None - raise e + raise diff --git a/src/froeling/datamodels/facility.py b/src/froeling/datamodels/facility.py index 5afd8f3..124103c 100644 --- a/src/froeling/datamodels/facility.py +++ b/src/froeling/datamodels/facility.py @@ -2,10 +2,10 @@ from dataclasses import dataclass -from ..session import Session -from .. import endpoints -from .generics import Address -from .component import Component +from froeling import endpoints +from froeling.datamodels.component import Component +from froeling.datamodels.generics import Address +from froeling.session import Session @dataclass(frozen=True) @@ -32,14 +32,14 @@ class Facility: @staticmethod def _from_dict(obj: dict, session: Session) -> 'Facility': facility_id = obj.get('facilityId') - assert isinstance(facility_id, int), f'facilityid was not an int.\nobj:{obj}' + if not isinstance(facility_id, int): + msg = f'facilityid was not an int.\nobj:{obj}' + raise TypeError(msg) equipment_number = obj.get('equipmentNumber') status = obj.get('status') name = obj.get('name') address_data = obj.get('address') - address = ( - Address._from_dict(address_data) if isinstance(address_data, dict) else None - ) + address = Address._from_dict(address_data) if isinstance(address_data, dict) else None # noqa: SLF001 owner = obj.get('owner') role = obj.get('role') favorite = obj.get('favorite') @@ -53,14 +53,14 @@ def _from_dict(obj: dict, session: Session) -> 'Facility': operation_hours: int | None = None if isinstance(protocol_3200_info, dict): hslm = protocol_3200_info.get('hoursSinceLastMaintenance') - if isinstance(hslm, (int, str)): + if isinstance(hslm, int | str): try: hours_since_last_maintenance = int(hslm) except ValueError: hours_since_last_maintenance = None op_hours = protocol_3200_info.get('operationHours') - if isinstance(op_hours, (int, str)): + if isinstance(op_hours, int | str): try: operation_hours = int(op_hours) except ValueError: @@ -96,10 +96,7 @@ async def get_components(self) -> list[Component | None]: 'get', endpoints.COMPONENT_LIST.format(self.session.user_id, self.facility_id), ) - return [ - Component._from_overview_data(self.facility_id, self.session, i) - for i in res # noqa: E501 - ] + return [Component._from_overview_data(self.facility_id, self.session, i) for i in res] # noqa: SLF001 def get_component(self, component_id: str) -> Component: """Get a component given it's id. diff --git a/src/froeling/datamodels/generics.py b/src/froeling/datamodels/generics.py index 8561ebd..8bc1454 100644 --- a/src/froeling/datamodels/generics.py +++ b/src/froeling/datamodels/generics.py @@ -1,7 +1,7 @@ """Generic datamodels used in multiple places/endpoints.""" -from enum import Enum from dataclasses import dataclass +from enum import Enum @dataclass(frozen=True) @@ -16,17 +16,17 @@ class Address: """ - street: str - zip: int - city: str - country: str + street: str | None + zip: int | None + city: str | None + country: str | None @staticmethod def _from_dict(obj: dict) -> 'Address': - street: str = obj.get('street', '') - zipcode: int = obj.get('zip', -1) - city: str = obj.get('city', '') - country: str = obj.get('country', '') + street = obj.get('street') + zipcode = obj.get('zip') + city = obj.get('city') + country = obj.get('country') return Address(street, zipcode, city, country) @@ -61,7 +61,7 @@ class TimeWindowDay: def _from_dict(cls, obj: dict) -> 'TimeWindowDay': _id = obj['id'] weekday = Weekday(obj['weekDay']) - phases = TimeWindowPhase._from_list(obj['phases']) + phases = TimeWindowPhase._from_list(obj['phases']) # noqa: SLF001 return cls(_id, weekday, phases) @@ -75,10 +75,10 @@ class TimeWindowPhase: """Represents a time phase within a single day. Attributes: - start_hour (int): Hour when the phase starts (0–23). - start_minute (int): Minute when the phase starts (0–59). - end_hour (int): Hour when the phase ends (0–23). - end_minute (int): Minute when the phase ends (0–59). + start_hour (int): Hour when the phase starts (0-23). + start_minute (int): Minute when the phase starts (0-59). + end_hour (int): Hour when the phase ends (0-23). + end_minute (int): Minute when the phase ends (0-59). """ diff --git a/src/froeling/datamodels/notifications.py b/src/froeling/datamodels/notifications.py index 95a05f8..be5e5d6 100644 --- a/src/froeling/datamodels/notifications.py +++ b/src/froeling/datamodels/notifications.py @@ -1,10 +1,13 @@ """Datamodels to represent Notifications and related objects.""" -from dataclasses import dataclass import datetime +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from froeling.session import Session -from .. import endpoints -from ..session import Session +from froeling import endpoints class NotificationOverview: @@ -45,10 +48,8 @@ def _set_data(self, data: dict) -> None: async def info(self) -> 'NotificationDetails': """Get additional information about this notification.""" - res = await self.session.request( - 'get', endpoints.NOTIFICATION.format(self.session.user_id, self.id) - ) - self.details = NotificationDetails._from_dict(res) + res = await self.session.request('get', endpoints.NOTIFICATION.format(self.session.user_id, self.id)) + self.details = NotificationDetails._from_dict(res) # noqa: SLF001 return self.details @@ -71,18 +72,12 @@ def _from_dict(cls, obj: dict) -> 'NotificationDetails': push = obj.get('push') submission_state = None if 'notificationSubmissionStateDto' in obj: - submission_state = NotificationSubmissionState._from_list( - obj['notificationSubmissionStateDto'] - ) + submission_state = NotificationSubmissionState._from_list(obj['notificationSubmissionStateDto']) # noqa: SLF001 error_solutions = None if 'errorSolutions' in obj: - error_solutions = NotificationErrorSolution._from_list( - obj['errorSolutions'] - ) - notification_details_object = cls( - body, sms, mail, push, submission_state, error_solutions - ) - notification_details_object._set_data(obj) + error_solutions = NotificationErrorSolution._from_list(obj['errorSolutions']) # noqa: SLF001 + notification_details_object = cls(body, sms, mail, push, submission_state, error_solutions) + notification_details_object._set_data(obj) # noqa: SLF001 return notification_details_object diff --git a/src/froeling/datamodels/userdata.py b/src/froeling/datamodels/userdata.py index 6586cd8..dea51aa 100644 --- a/src/froeling/datamodels/userdata.py +++ b/src/froeling/datamodels/userdata.py @@ -1,7 +1,8 @@ """Datamodels related to the user account.""" from dataclasses import dataclass -from .generics import Address + +from froeling.datamodels.generics import Address @dataclass(frozen=True) @@ -28,9 +29,7 @@ def _from_dict(obj: dict) -> 'UserData': firstname: str | None = user_data.get('firstname') surname: str | None = user_data.get('surname') - address: Address | None = ( - Address._from_dict(user_data['address']) if 'address' in user_data else None - ) + address: Address | None = Address._from_dict(user_data['address']) if 'address' in user_data else None # noqa: SLF001 user_id: int = user_data.get('userId', -1) lang: str | None = obj.get('lang') diff --git a/src/froeling/endpoints.py b/src/froeling/endpoints.py index 57dc383..2dec52c 100644 --- a/src/froeling/endpoints.py +++ b/src/froeling/endpoints.py @@ -6,15 +6,11 @@ USER = 'https://connect-api.froeling.com/connect/v1.0/resources/service/user/{}' """1: user_id""" -FACILITY = ( - 'https://connect-api.froeling.com/connect/v1.0/resources/service/user/{}/facility' -) +FACILITY = 'https://connect-api.froeling.com/connect/v1.0/resources/service/user/{}/facility' """1: user_id facility_ids = res[*]["facilityId"]""" -OVERVIEW = ( - 'https://connect-api.froeling.com/fcs/v1.0/resources/user/{}/facility/{}/overview' -) +OVERVIEW = 'https://connect-api.froeling.com/fcs/v1.0/resources/user/{}/facility/{}/overview' """1: user_id 2: facility_id""" COMPONENT_LIST = 'https://connect-api.froeling.com/fcs/v1.0/resources/user/{}/facility/{}/componentList' diff --git a/src/froeling/exceptions.py b/src/froeling/exceptions.py index be21564..22fbae3 100644 --- a/src/froeling/exceptions.py +++ b/src/froeling/exceptions.py @@ -3,19 +3,18 @@ from aiohttp.typedefs import StrOrURL -class AuthenticationError(BaseException): +class AuthenticationError(Exception): """Raised for unauthorized requests, failed reauths, or bad credentials.""" - pass - -class NetworkError(BaseException): +class NetworkError(Exception): """Raised on unsuccessful HTTP status codes.""" def __init__(self, msg: str, status: int, url: StrOrURL, res: str) -> None: """Initialize a NetworkError. Args: + ---- msg (str): Short description of the error. status (int): HTTP status code returned by the request. url (StrOrURL): The requested URL. @@ -27,7 +26,7 @@ def __init__(self, msg: str, status: int, url: StrOrURL, res: str) -> None: self.url = url -class ParsingError(BaseException): +class ParsingError(Exception): """Raised when parsing an API response fails. Attributes: @@ -44,6 +43,7 @@ def __init__(self, msg: str, doc: str, pos: int, url: StrOrURL) -> None: """Initialize a ParsingError. Args: + ---- msg (str): Description of the parsing error. doc (str): The raw response text. pos (int): Character position in `doc` where the error occurred. @@ -64,3 +64,12 @@ def __init__(self, msg: str, doc: str, pos: int, url: StrOrURL) -> None: self.url = url self.lineno = lineno self.colno = colno + + +class FacilityNotFoundError(Exception): + """Raised when a requested facility does not exist.""" + + def __init__(self, facility_id: int): + super().__init__(f'Could not find facility with id {facility_id}.') + + self.facility_id = facility_id diff --git a/src/froeling/session.py b/src/froeling/session.py index f1ec93c..a46b7eb 100644 --- a/src/froeling/session.py +++ b/src/froeling/session.py @@ -1,13 +1,19 @@ """Manages authentication, requests and error handling.""" -from typing import Callable, Any -from aiohttp import ClientSession -from aiohttp.typedefs import StrOrURL -import json import base64 +import json import logging +from collections.abc import Callable +from http import HTTPStatus +from typing import Any + +from aiohttp import ClientSession +from aiohttp.typedefs import StrOrURL + +from froeling import endpoints, exceptions -from . import endpoints, exceptions +HTTP_STATUS_SUCCESS_MIN = 200 +HTTP_STATUS_SUCCESS_MAX = 299 class Session: @@ -32,6 +38,7 @@ def __init__( username: str | None = None, password: str | None = None, token: str | None = None, + *, auto_reauth: bool = False, token_callback: Callable[[str], Any] | None = None, lang: str = 'en', @@ -45,6 +52,7 @@ def __init__( the session will attempt to automatically refresh expired tokens. Args: + ---- username (str | None): Username for authentication. password (str | None): Password for authentication. token (str | None): Existing authentication token. @@ -58,12 +66,12 @@ def __init__( client session to reuse instead of creating a new one. """ - assert token or (username and password), ( - 'Set either token or username and password.' - ) - assert not (auto_reauth and not (username and password)), ( - 'Set username and password to use auto_reauth.' - ) + if not (token or (username and password)): + msg = 'Set either token or username and password.' + raise ValueError(msg) + if auto_reauth and not (username and password): + msg = 'Set username and password to use auto_reauth.' + raise ValueError(msg) self.clientsession = clientsession or ClientSession() self._headers = {'Accept-Language': lang} @@ -76,9 +84,7 @@ def __init__( self.set_token(token) self._logger = logger or logging.getLogger(__name__) - self._reauth_previous = ( - False # Did the previous request result in renewing the token? - ) + self._reauth_previous = False # Did the previous request result in renewing the token? async def close(self) -> None: """Close the session.""" @@ -91,18 +97,11 @@ def set_token(self, token: str) -> None: """ self._headers['Authorization'] = token try: - self.user_id = json.loads( - base64.b64decode(token.split('.')[1] + '==').decode('utf-8') - )['userId'] + self.user_id = json.loads(base64.b64decode(token.split('.')[1] + '==').decode('utf-8'))['userId'] except Exception as e: - raise ValueError('Token is in an invalid format.') from e - if ( - self.token_callback and self.token - ): # Only run when overwriting existing token - self.token = token - self.token_callback(token) - else: - self.token = token + msg = 'Token is in an invalid format.' + raise ValueError(msg) from e + self.token = token async def login(self) -> dict: """Get a token using username and password. @@ -111,18 +110,18 @@ async def login(self) -> dict: """ data = {'osType': 'web', 'username': self.username, 'password': self.password} async with await self.clientsession.post(endpoints.LOGIN, json=data) as res: - if res.status != 200: - raise exceptions.AuthenticationError( - f'Server returned {res.status}: "{await res.text()}"' - ) - self.set_token(res.headers['Authorization']) + if not HTTP_STATUS_SUCCESS_MIN <= res.status <= HTTP_STATUS_SUCCESS_MAX: + msg = f'Server returned {res.status}: "{await res.text()}"' + raise exceptions.AuthenticationError(msg) + token = res.headers['Authorization'] + if self.token_callback: + self.token_callback(token) + self.set_token(token) userdata = await res.json() self._logger.debug('Logged in with username and password.') return userdata - async def request( - self, method: str, url: StrOrURL, headers: dict | None = None, **kwargs: Any - ) -> Any: + async def request(self, method: str, url: StrOrURL, headers: dict | None = None, **kwargs: Any) -> Any: """Do a web request. :param method: @@ -136,41 +135,36 @@ async def request( request_headers |= headers try: - async with await self.clientsession.request( - method, url, headers=request_headers, **kwargs - ) as res: - if 299 >= res.status >= 200: + async with await self.clientsession.request(method, url, headers=request_headers, **kwargs) as res: + if HTTP_STATUS_SUCCESS_MIN <= res.status <= HTTP_STATUS_SUCCESS_MAX: r = await res.text() self._logger.debug('Got %s', r) self._reauth_previous = False return await res.json() - if res.status == 401: + if res.status == HTTPStatus.UNAUTHORIZED: if self.auto_reauth: if self._reauth_previous: - raise exceptions.AuthenticationError( - 'Reauth did not work.', await res.text() - ) - self._logger.info( - 'Error %s, renewing token...', await res.text() - ) + msg = 'Reauth did not work.' + raise exceptions.AuthenticationError(msg, await res.text()) + self._logger.info('Error %s, renewing token...', await res.text()) await self.login() self._logger.info('Reauthorized.') self._reauth_previous = True return await self.request(method, url, **kwargs) - else: - self._logger.error('Request unauthorized') - raise exceptions.AuthenticationError( - 'Request not authorized: ', await res.text() - ) - else: - error_data = await res.text() - raise exceptions.NetworkError( - 'Unexpected return code', - status=res.status, - url=res.url, - res=error_data, - ) + + self._logger.error('Request unauthorized') + msg = 'Request not authorized: ' + raise exceptions.AuthenticationError(msg, await res.text()) + + error_data = await res.text() + msg = 'Unexpected return code' + raise exceptions.NetworkError( + msg, + status=res.status, + url=res.url, + res=error_data, + ) except json.decoder.JSONDecodeError as e: - raise exceptions.ParsingError(e.msg, e.doc, e.pos, url) + raise exceptions.ParsingError(e.msg, e.doc, e.pos, url) from e diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a708570 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +import json +from pathlib import Path + +import pytest + + +@pytest.fixture +def load_json(): + """Load captured JSON responses from tests/responses/.""" + + def _loader(name: str): + path = Path(__file__).parent / 'responses' / name + with open(path, encoding='utf-8') as f: + return json.load(f) + + return _loader diff --git a/tests/responses/component.json b/tests/responses/component.json new file mode 100644 index 0000000..bf81315 --- /dev/null +++ b/tests/responses/component.json @@ -0,0 +1,451 @@ +{ + "componentId": "1_100", + "displayName": "DN_Kessel", + "displayCategory": "DC_Kessel", + "standardName": "SN_Kessel", + "componentNumber": 1, + "type": "BOILER", + "subType": "WOODCHIP", + "topView": { + "pictureUrl": "https://connect-api.froeling.com/ctvps/v1.0/resources/service/componentTopViewPicture?xxxx", + "pictureParams": { + "boilerTemp": { + "id": "3_0", + "displayName": "Kesseltemperatur", + "name": "boilerTemp", + "editable": false, + "parameterType": "NumValueObject", + "unit": "°C", + "value": "78", + "minVal": "-16000", + "maxVal": "16000" + }, + "flueGasTemp": { + "id": "3_1", + "displayName": "Abgastemperatur", + "name": "flueGasTemp", + "editable": false, + "parameterType": "NumValueObject", + "unit": "°C", + "value": "72", + "minVal": "-32000", + "maxVal": "32000" + }, + "fanControl": { + "id": "3_15", + "displayName": "Saugzug - Ansteuerung", + "name": "fanControl", + "editable": false, + "parameterType": "NumValueObject", + "unit": "%", + "value": "0", + "minVal": "-32000", + "maxVal": "32000" + }, + "resOxygenContent": { + "id": "3_3", + "displayName": "Restsauerstoffgehalt", + "name": "resOxygenContent", + "editable": false, + "parameterType": "NumValueObject", + "unit": "%", + "value": "1.9", + "minVal": "-3200.0", + "maxVal": "3200.0" + }, + "returnFlowTemp": { + "id": "3_156", + "displayName": "Rücklauffühler", + "name": "returnFlowTemp", + "editable": false, + "parameterType": "NumValueObject", + "unit": "°C", + "value": "67", + "minVal": "-16000", + "maxVal": "16000" + }, + "state": { + "id": "77_457", + "displayName": "Kesselzustand", + "name": "state", + "editable": false, + "parameterType": "StringValueObject", + "unit": "", + "value": "19", + "minVal": "-32000", + "maxVal": "32000", + "stringListKeyValues": { + "0": "STÖRUNG", + "1": "Kessel Aus", + "2": "Anheizen", + "3": "Heizen", + "4": "Feuererhaltung", + "5": "Feuer Aus", + "6": "Tür offen", + "7": "Vorbereitung", + "8": "Vorwärmen", + "9": "Zünden", + "10": "Abstellen Warten", + "11": "Abstellen Warten1", + "12": "Abstellen Einschub1", + "13": "Abstellen Warten2", + "14": "Abstellen Einschub2", + "15": "Abreinigen", + "16": "2h warten", + "17": "Saugen / Heizen", + "18": "Fehlzündung", + "19": "Betriebsbereit", + "20": "Rost schließen", + "21": "Stoker leeren", + "22": "Vorheizen", + "23": "Saugen", + "24": "RSE schließen", + "25": "RSE öffnen", + "26": "Rost kippen", + "27": "Vorwärmen-Zünden", + "28": "Resteinschub", + "29": "Stoker auffüllen", + "30": "Lambdasonde aufheizen", + "31": "Gebläsenachlauf I", + "32": "Gebläsenachlauf II", + "33": "Abgestellt", + "34": "Nachzünden", + "35": "Zünden Warten", + "36": "FB: RSE schließen", + "37": "FB: Kessel belüften", + "38": "FB: Zünden", + "39": "FB: min. Einschub", + "40": "RSE schließen", + "41": "STÖRUNG: STB/NA", + "42": "STÖRUNG: Kipprost", + "43": "STÖRUNG: FR-Überdr.", + "44": "STÖRUNG: Türkont.", + "45": "STÖRUNG: Saugzug", + "46": "STÖRUNG: Umfeld", + "47": "FEHLER: STB/NA", + "48": "FEHLER: Kipprost", + "49": "FEHLER: FR-Überdr.", + "50": "FEHLER: Türkont.", + "51": "FEHLER: Saugzug", + "52": "FEHLER: Umfeld", + "53": "FEHLER: Stoker", + "54": "STÖRUNG: Stoker", + "55": "FB: Stoker leeren", + "56": "Vorbelüften", + "57": "STÖRUNG: Hackgut", + "58": "FEHLER: Hackgut", + "59": "NB: Tür offen", + "60": "NB: Anheizen", + "61": "NB: Heizen", + "62": "FEHLER: STB/NA", + "63": "FEHLER: Allgemein", + "64": "NB: Feuer Aus", + "65": "Selbsttest aktiv", + "66": "Fehlerbeh. 20min", + "67": "FEHLER: Fallschacht", + "68": "STÖRUNG: Fallschacht", + "69": "Reinigen möglich", + "70": "Heizen - Reinigen", + "71": "SH Anheizen", + "72": "SH Heizen", + "73": "SH Heiz/Abstell", + "74": "STÖRUNG sicher", + "75": "AGR Nachlauf", + "76": "AGR reinigen", + "77": "Zündung AUS", + "78": "E-Abscheider reinigen", + "79": "Anheizassistent", + "80": "SH Zünden", + "81": "SH Störung", + "82": "Sensorcheck", + "83": "Feuererhaltung", + "84": "Tür offen", + "85": "Tür offen", + "86": "Heizen", + "87": "Mantelkühlung", + "88": "Mantelkühlung" + } + }, + "bufferPumpControl": { + "id": "3_140", + "displayName": "Pufferpumpen Ansteuerung", + "name": "bufferPumpControl", + "editable": false, + "parameterType": "NumValueObject", + "unit": "%", + "value": "0", + "minVal": "-32000", + "maxVal": "32000" + } + }, + "infoParams": { + "state": { + "id": "77_457", + "displayName": "Kesselzustand", + "name": "state", + "editable": false, + "parameterType": "StringValueObject", + "unit": "", + "value": "19", + "minVal": "-32000", + "maxVal": "32000", + "stringListKeyValues": { + "0": "STÖRUNG", + "1": "Kessel Aus", + "2": "Anheizen", + "3": "Heizen", + "4": "Feuererhaltung", + "5": "Feuer Aus", + "6": "Tür offen", + "7": "Vorbereitung", + "8": "Vorwärmen", + "9": "Zünden", + "10": "Abstellen Warten", + "11": "Abstellen Warten1", + "12": "Abstellen Einschub1", + "13": "Abstellen Warten2", + "14": "Abstellen Einschub2", + "15": "Abreinigen", + "16": "2h warten", + "17": "Saugen / Heizen", + "18": "Fehlzündung", + "19": "Betriebsbereit", + "20": "Rost schließen", + "21": "Stoker leeren", + "22": "Vorheizen", + "23": "Saugen", + "24": "RSE schließen", + "25": "RSE öffnen", + "26": "Rost kippen", + "27": "Vorwärmen-Zünden", + "28": "Resteinschub", + "29": "Stoker auffüllen", + "30": "Lambdasonde aufheizen", + "31": "Gebläsenachlauf I", + "32": "Gebläsenachlauf II", + "33": "Abgestellt", + "34": "Nachzünden", + "35": "Zünden Warten", + "36": "FB: RSE schließen", + "37": "FB: Kessel belüften", + "38": "FB: Zünden", + "39": "FB: min. Einschub", + "40": "RSE schließen", + "41": "STÖRUNG: STB/NA", + "42": "STÖRUNG: Kipprost", + "43": "STÖRUNG: FR-Überdr.", + "44": "STÖRUNG: Türkont.", + "45": "STÖRUNG: Saugzug", + "46": "STÖRUNG: Umfeld", + "47": "FEHLER: STB/NA", + "48": "FEHLER: Kipprost", + "49": "FEHLER: FR-Überdr.", + "50": "FEHLER: Türkont.", + "51": "FEHLER: Saugzug", + "52": "FEHLER: Umfeld", + "53": "FEHLER: Stoker", + "54": "STÖRUNG: Stoker", + "55": "FB: Stoker leeren", + "56": "Vorbelüften", + "57": "STÖRUNG: Hackgut", + "58": "FEHLER: Hackgut", + "59": "NB: Tür offen", + "60": "NB: Anheizen", + "61": "NB: Heizen", + "62": "FEHLER: STB/NA", + "63": "FEHLER: Allgemein", + "64": "NB: Feuer Aus", + "65": "Selbsttest aktiv", + "66": "Fehlerbeh. 20min", + "67": "FEHLER: Fallschacht", + "68": "STÖRUNG: Fallschacht", + "69": "Reinigen möglich", + "70": "Heizen - Reinigen", + "71": "SH Anheizen", + "72": "SH Heizen", + "73": "SH Heiz/Abstell", + "74": "STÖRUNG sicher", + "75": "AGR Nachlauf", + "76": "AGR reinigen", + "77": "Zündung AUS", + "78": "E-Abscheider reinigen", + "79": "Anheizassistent", + "80": "SH Zünden", + "81": "SH Störung", + "82": "Sensorcheck", + "83": "Feuererhaltung", + "84": "Tür offen", + "85": "Tür offen", + "86": "Heizen", + "87": "Mantelkühlung", + "88": "Mantelkühlung" + } + }, + "operationHours": { + "id": "70_98", + "displayName": "Betriebsstunden", + "name": "operationHours", + "editable": false, + "parameterType": "NumValueObject", + "unit": "h", + "value": "1234", + "minVal": "0", + "maxVal": "65535" + }, + "hoursSinceLastMaintenance": { + "id": "3_213", + "displayName": "Stunden seit letzter Wartung", + "name": "hoursSinceLastMaintenance", + "editable": false, + "parameterType": "NumValueObject", + "unit": "h", + "value": "123", + "minVal": "-32000", + "maxVal": "32000" + } + }, + "configParams": { + "mode2": { + "id": "100011_9963", + "displayName": "Kessel Zustand", + "name": "mode2", + "editable": true, + "parameterType": "StringValueObject", + "unit": "", + "value": "2", + "minVal": "0", + "maxVal": "2", + "stringListKeyValues": { + "0": "Dauerlast", + "1": "Brauchwasser", + "2": "Automatik" + } + }, + "boilerOn": { + "id": "995_9887", + "displayName": "Kessel EIN", + "name": "boilerOn", + "editable": true, + "parameterType": "StringValueObject", + "unit": "", + "value": "0", + "minVal": "-1", + "maxVal": "1", + "stringListKeyValues": { + "0": "Kessel EIN", + "1": "Kessel AUS" + } + }, + "remoteOn": { + "id": "8_1085", + "displayName": "Fernschalten über connect möglich", + "name": "remoteOn", + "editable": true, + "parameterType": "StringValueObject", + "unit": "", + "value": "1", + "minVal": "0", + "maxVal": "1", + "stringListKeyValues": { + "0": "NEIN", + "1": "JA" + } + }, + "ignitionWhenBufferTempBelow": { + "id": "CAL_B_1", + "displayName": "Zündung wenn Buffertemperatur unter", + "name": "ignitionWhenBufferTempBelow", + "editable": false, + "parameterType": "NumValueObject", + "unit": "°C", + "value": "60.0", + "minVal": "0", + "maxVal": "0" + }, + "ignitionConfigured": { + "id": "INL_B_1", + "displayName": "Zündung konfiguriert", + "name": "ignitionConfigured", + "editable": false, + "parameterType": "NumValueObject", + "unit": "", + "value": "0", + "minVal": "0", + "maxVal": "1" + } + } + }, + "timeWindowsView": [], + "stateView": [{ + "id": "3_0", + "displayName": "Kesseltemperatur", + "name": "boilerTemp", + "editable": false, + "parameterType": "NumValueObject", + "unit": "°C", + "value": "78", + "minVal": "-16000", + "maxVal": "16000" + }, { + "id": "3_1", + "displayName": "Abgastemperatur", + "name": "flueGasTemp", + "editable": false, + "parameterType": "NumValueObject", + "unit": "°C", + "value": "72", + "minVal": "-32000", + "maxVal": "32000" + }, { + "id": "3_15", + "displayName": "Saugzug - Ansteuerung", + "name": "fanControl", + "editable": false, + "parameterType": "NumValueObject", + "unit": "%", + "value": "0", + "minVal": "-32000", + "maxVal": "32000" + }, { + "id": "3_3", + "displayName": "Restsauerstoffgehalt", + "name": "resOxygenContent", + "editable": false, + "parameterType": "NumValueObject", + "unit": "%", + "value": "1.9", + "minVal": "-3200.0", + "maxVal": "3200.0" + }, { + "id": "3_16", + "displayName": "Luftklappenansteuerung", + "name": "primaryAir", + "editable": false, + "parameterType": "NumValueObject", + "unit": "%", + "value": "0", + "minVal": "-32000", + "maxVal": "32000" + }, { + "id": "3_156", + "displayName": "Rücklauffühler", + "name": "returnFlowTemp", + "editable": false, + "parameterType": "NumValueObject", + "unit": "°C", + "value": "67", + "minVal": "-16000", + "maxVal": "16000" + }], + "setupView": [{ + "id": "7_28", + "displayName": "Kessel-Solltemperatur", + "name": "boilerSetTemp", + "editable": true, + "parameterType": "NumValueObject", + "unit": "°C", + "value": "80", + "minVal": "60", + "maxVal": "90" + }] +} diff --git a/tests/responses/component_list.json b/tests/responses/component_list.json new file mode 100644 index 0000000..7479abf --- /dev/null +++ b/tests/responses/component_list.json @@ -0,0 +1,45 @@ +[{ + "dtoType": "componentListing", + "displayName": "some display name", + "displayCategory": "Kessel", + "componentId": "1_100", + "standardName": "Kessel", + "componentNumber": 1, + "type": "BOILER", + "subType": "WOODCHIP" +}, { + "dtoType": "componentListing", + "displayName": "Circuit 1", + "displayCategory": "Heizkreis", + "componentId": "300_3100", + "standardName": "Heizkreis 01", + "componentNumber": 1, + "type": "CIRCUIT", + "subType": "OUT_TEMP_CRTL" +}, { + "dtoType": "componentListing", + "displayName": "Circuit 2", + "displayCategory": "Heizkreis", + "componentId": "300_3110", + "standardName": "Heizkreis 02", + "componentNumber": 2, + "type": "CIRCUIT", + "subType": "OUT_TEMP_CRTL" +}, { + "dtoType": "componentListing", + "displayName": "Boiler 01", + "displayCategory": "Boiler", + "componentId": "200_2100", + "standardName": "Boiler 01", + "componentNumber": 1, + "type": "DHW" +}, { + "dtoType": "componentListing", + "displayName": "Puffer 01", + "displayCategory": "Puffer", + "componentId": "400_4100", + "standardName": "Puffer 01", + "componentNumber": 1, + "type": "BUFFER_TANK", + "subType": "NEW_GENERATION" +}] diff --git a/tests/responses/facility.json b/tests/responses/facility.json new file mode 100644 index 0000000..42540b2 --- /dev/null +++ b/tests/responses/facility.json @@ -0,0 +1,51 @@ +[{ + "facilityId": 12345, + "equipmentNumber": 100321123, + "status": "OK", + "name": "Facility name", + "address": { + "street": "some street", + "zip": "3210", + "city": "somewhere", + "country": "DE" + }, + "owner": "Jimmy Smith", + "role": "OWNER", + "favorite": false, + "allowMessages": true, + "subscribedNotifications": true, + "pictureUrl": "https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/1234/facility/12345/picture", + "protocol3200Info": { + "hoursSinceLastMaintenance": "1000", + "operationHours": "3000", + "active": false, + "productType": "T4e 230-250", + "status": "OK" + }, + "facilityGeneration": "GEN_3200" +}, { + "facilityId": 54321, + "equipmentNumber": 100123321, + "status": "OK", + "name": "Another facility name", + "address": { + "street": "some street", + "zip": "3210", + "city": "somewhere", + "country": "DE" + }, + "owner": "Heisenberg", + "role": "OWNER", + "favorite": false, + "allowMessages": true, + "subscribedNotifications": true, + "pictureUrl": "https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/1234/facility/12345/picture", + "protocol3200Info": { + "hoursSinceLastMaintenance": "2000", + "operationHours": "200", + "active": false, + "productType": "S4 Turbo 60", + "status": "OK" + }, + "facilityGeneration": "GEN_3200" +}] diff --git a/tests/responses/facility_modified.json b/tests/responses/facility_modified.json new file mode 100644 index 0000000..025d4e8 --- /dev/null +++ b/tests/responses/facility_modified.json @@ -0,0 +1,58 @@ +[{ + "facilityId": 12345, + "status": "OK", + "name": "Facility name", + "address": { + "street": "some street", + "zip": "ABCD", + "city": "somewhere", + "country": "DE" + }, + "owner": "Jimmy Smith", + "role": "OWNER", + "favorite": false, + "allowMessages": true, + "subscribedNotifications": true, + "facilityGeneration": "GEN_500" +}, { + "facilityId": 17, + "equipmentNumber": 100123321, + "status": "OK", + "name": "Another facility name", + "address": {}, + "owner": "Slippy Jim", + "role": "OWNER", + "favorite": false, + "allowMessages": true, + "subscribedNotifications": true, + "pictureUrl": "https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/1234/facility/12345/picture", + "protocol3200Info": { + "hoursSinceLastMaintenance": "2000", + "operationHours": "200", + "active": false, + "productType": "AMG GT 63", + "status": "BAD" + }, + "facilityGeneration": "GEN_3200" +}, { + "facilityId": 0, + "equipmentNumber": -567, + "status": "NOTOK", + "name": "Another facility name", + "address": null, + "owner": "Slippy Jim", + "role": "OWNER", + "favorite": false, + "allowMessages": true, + "subscribedNotifications": true, + "pictureUrl": "https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/1234/facility/12345/picture", + "protocol6400Info": { + "hoursSinceLastMaintenance": "2000", + "operationHours": "200", + "active": false, + "productType": "AMG GT 63", + "status": "BAD", + "mood": "Annoyed" + }, + "facilityGeneration": "GEN_6400" +}] diff --git a/tests/responses/login.json b/tests/responses/login.json new file mode 100644 index 0000000..89f1414 --- /dev/null +++ b/tests/responses/login.json @@ -0,0 +1,29 @@ +{ + "userData": { + "email": "user@example.com", + "salutation": "MR", + "firstname": "James", + "surname": "Doe", + "address": { + "street": "Sesame street", + "zip": "12345", + "city": "Somewhere", + "country": "DE" + }, + "userId": 12345, + "createdOn": "2020-11-11T11:11:11.870Z" + }, + "lang": "de", + "role": "USER", + "active": true, + "pictureUrl": "https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/12345/picture", + "viewConfig": { + "facilitiesListView": "grid", + "serviceFacilitiesListView": "list", + "componentStartView": "component", + "facilitiesDashboardDefaultMode": "all", + "serviceFacilitiesDashboardDefaultMode": "all" + }, + "permissions": [], + "temperatureUnit": "Celsius" +} diff --git a/tests/responses/login_bad_creds.json b/tests/responses/login_bad_creds.json new file mode 100644 index 0000000..3eb63e3 --- /dev/null +++ b/tests/responses/login_bad_creds.json @@ -0,0 +1,4 @@ +{ + "code": "ECON_wrongUsernameOrPassword", + "message": "Falscher Benutzername oder Passwort" +} diff --git a/tests/responses/notification.json b/tests/responses/notification.json new file mode 100644 index 0000000..a440ce2 --- /dev/null +++ b/tests/responses/notification.json @@ -0,0 +1,26 @@ +{ + "id": 10123456, + "subject": "Subject 1", + "body": "Title\r\ntext", + "sms": false, + "mail": true, + "push": true, + "unread": false, + "notificationDate": "2025-01-02T12:34:56.789+00:00", + "notificationType": "INFO", + "facilityId": 12345, + "facilityName": "F1", + "notificationSubmissionStateDto": [{ + "id": 12345678, + "recipient": "joe@example.com", + "type": "EMAIL", + "submittedTo": "joe@example.com", + "submissionResult": "SUCCESS" + }, { + "id": 12345679, + "recipient": "sometoken", + "type": "TOKEN", + "submittedTo": "joe@example.com", + "submissionResult": "SUCCESS" + }] +} diff --git a/tests/responses/notification_count.json b/tests/responses/notification_count.json new file mode 100644 index 0000000..edc6ea0 --- /dev/null +++ b/tests/responses/notification_count.json @@ -0,0 +1,3 @@ +{ + "unreadNotifications": 123 +} diff --git a/tests/responses/notification_list.json b/tests/responses/notification_list.json new file mode 100644 index 0000000..5c072ca --- /dev/null +++ b/tests/responses/notification_list.json @@ -0,0 +1,23 @@ +[{ + "id": 10123456, + "subject": "Subject 1", + "unread": false, + "notificationDate": "2025-01-02T12:34:56.789+00:00", + "errorId": null, + "notificationType": "INFO", + "facilityId": 12345, + "facilityName": "F1" +}, { + "id": 20123456, + "subject": "Subject 2", + "unread": true, + "notificationDate": "2025-01-02T12:34:56.789+00:00", + "errorId": 0, + "notificationType": "ALARM" +}, { + "id": 30123456, + "subject": "Subject 3", + "notificationDate": "2025-01-02T12:34:56.789+00:00", + "errorId": 404, + "notificationType": "ERROR" +}] diff --git a/tests/responses/overview.json b/tests/responses/overview.json new file mode 100644 index 0000000..9062795 --- /dev/null +++ b/tests/responses/overview.json @@ -0,0 +1,403 @@ +{ + "outTemp": { + "displayName": "Outside air temperature", + "value": "20", + "unit": "°C" + }, + "components": [{ + "displayName": "Boiler", + "componentNumber": 1, + "componentId": "1_100", + "type": "BOILER", + "subType": "WOODCHIP", + "active": false, + "state": { + "displayName": "Kesselzustand", + "displayValue": "Standby", + "value": "19" + }, + "mode2": { + "displayName": "Boiler status", + "displayValue": "Automatic", + "value": "2" + }, + "boilerTemp": { + "displayName": "Boiler temperature", + "value": "76", + "unit": "°C" + }, + "boilerOn": { + "displayName": "Boiler ON", + "displayValue": "Boiler ON", + "value": "0" + }, + "ignitionWhenBufferTempBelow": { + "displayName": "Ignition when buffer temperature below", + "value": "60.0", + "unit": "°C" + }, + "ignitionConfigured": { + "displayName": "Ignition configured", + "value": "0", + "unit": "" + } + }, { + "displayName": "Circuit 1 name", + "displayCategory": "Heating Circuit 01", + "componentNumber": 1, + "componentId": "300_3100", + "type": "CIRCUIT", + "subType": "OUT_TEMP_CRTL", + "active": false, + "heatingPhase": [{ + "weekDay": "MONDAY", + "phases": [{ + "startHour": 6, + "startMinute": 0, + "endHour": 20, + "endMinute": 0 + }] + }, { + "weekDay": "TUESDAY", + "phases": [{ + "startHour": 6, + "startMinute": 10, + "endHour": 20, + "endMinute": 0 + }] + }, { + "weekDay": "WEDNESDAY", + "phases": [{ + "startHour": 6, + "startMinute": 10, + "endHour": 20, + "endMinute": 0 + }] + }, { + "weekDay": "THURSDAY", + "phases": [{ + "startHour": 6, + "startMinute": 10, + "endHour": 20, + "endMinute": 0 + }] + }, { + "weekDay": "FRIDAY", + "phases": [{ + "startHour": 6, + "startMinute": 10, + "endHour": 19, + "endMinute": 0 + }] + }, { + "weekDay": "SATURDAY", + "phases": [{ + "startHour": 7, + "startMinute": 0, + "endHour": 16, + "endMinute": 0 + }] + }, { + "weekDay": "SUNDAY", + "phases": [{ + "startHour": 9, + "startMinute": 0, + "endHour": 16, + "endMinute": 0 + }] + }], + "mode": { + "displayName": "Heating circuit mode", + "displayValue": "OFF", + "value": "0" + }, + "desiredRoomTemp": { + "displayName": "Switch off heating circuit pump when outfeed setpoint is lower than", + "value": "20", + "unit": "°C" + }, + "actualFlowTemp": { + "displayName": "Actual flow temperature", + "value": "39", + "unit": "°C" + }, + "circuitPumpControl": { + "displayName": "Heating circuit pump", + "value": "0", + "unit": "" + } + }, { + "displayName": "Circuit 2 name", + "displayCategory": "Heating Circuit 02", + "componentNumber": 2, + "componentId": "300_3110", + "type": "CIRCUIT", + "subType": "OUT_TEMP_CRTL", + "active": false, + "heatingPhase": [{ + "weekDay": "MONDAY", + "phases": [{ + "startHour": 5, + "startMinute": 40, + "endHour": 21, + "endMinute": 40 + }] + }, { + "weekDay": "TUESDAY", + "phases": [{ + "startHour": 5, + "startMinute": 40, + "endHour": 21, + "endMinute": 40 + }] + }, { + "weekDay": "WEDNESDAY", + "phases": [{ + "startHour": 5, + "startMinute": 40, + "endHour": 21, + "endMinute": 40 + }] + }, { + "weekDay": "THURSDAY", + "phases": [{ + "startHour": 5, + "startMinute": 40, + "endHour": 21, + "endMinute": 40 + }] + }, { + "weekDay": "FRIDAY", + "phases": [{ + "startHour": 5, + "startMinute": 40, + "endHour": 21, + "endMinute": 40 + }] + }, { + "weekDay": "SATURDAY", + "phases": [{ + "startHour": 5, + "startMinute": 40, + "endHour": 21, + "endMinute": 40 + }] + }, { + "weekDay": "SUNDAY", + "phases": [{ + "startHour": 5, + "startMinute": 40, + "endHour": 21, + "endMinute": 40 + }] + }], + "mode": { + "displayName": "Heating circuit mode", + "displayValue": "OFF", + "value": "0" + }, + "desiredRoomTemp": { + "displayName": "Switch off heating circuit pump when outfeed setpoint is lower than", + "value": "20", + "unit": "°C" + }, + "actualFlowTemp": { + "displayName": "Actual flow temperature", + "value": "35", + "unit": "°C" + }, + "circuitPumpControl": { + "displayName": "Heating circuit pump", + "value": "0", + "unit": "" + } + }, { + "displayName": "DHW 1", + "componentNumber": 1, + "componentId": "200_2100", + "type": "DHW", + "active": false, + "heatingPhase": [{ + "weekDay": "MONDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "TUESDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "WEDNESDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "THURSDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "FRIDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "SATURDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "SUNDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }], + "mode": { + "displayName": "DHW tank mode", + "displayValue": "Auto", + "value": "1" + }, + "setDhwTemp": { + "displayName": "Set DHW temperature", + "value": "52", + "unit": "°C" + }, + "dhwPumpControl": { + "displayName": "DHW tank pump control", + "value": "0", + "unit": "%" + }, + "dhwTempTop": { + "displayName": "DHW tank top temperature", + "value": "74", + "unit": "°C" + } + }, { + "displayName": "Buffer tank 1", + "componentNumber": 1, + "componentId": "400_4100", + "type": "BUFFER_TANK", + "subType": "NEW_GENERATION", + "active": false, + "svgUrl": "https://connect-api.froeling.com/ctvps/v1.0/resources/service/componentTopViewPicture?...", + "heatingPhase": [{ + "weekDay": "MONDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "TUESDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "WEDNESDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "THURSDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "FRIDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "SATURDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }, { + "weekDay": "SUNDAY", + "phases": [{ + "startHour": 0, + "startMinute": 0, + "endHour": 24, + "endMinute": 0 + }] + }], + "bufferTempTop": { + "displayName": "Buffer tank top temperature", + "value": "20", + "unit": "°C" + }, + "bufferTempMiddleNewGeneration2": { + "displayName": "Storage tank temperature, sensor 2", + "value": "20", + "unit": "°C" + }, + "bufferTempMiddleNewGeneration3": { + "displayName": "Storage tank temperature, sensor 3", + "value": "20", + "unit": "°C" + }, + "bufferTempBottom": { + "displayName": "Buffer tank bottom temperature", + "value": "20", + "unit": "°C" + }, + "bufferSensorAmount": { + "displayName": "Sensor for storage tank 1 with multi-sensor management", + "value": "4", + "unit": "" + }, + "bufferTankChargeDiskret": { + "displayName": "Pufferladezustand Diskret", + "value": "4", + "unit": "" + }, + "bufferTankCharge": { + "displayName": "Buffer tank charge", + "value": "97", + "unit": "%" + }, + "bufferPumpControl": { + "displayName": "Buffer tank pump control", + "value": "0", + "unit": "%" + } + }] +} diff --git a/tests/responses/user.json b/tests/responses/user.json new file mode 100644 index 0000000..6e8d6a9 --- /dev/null +++ b/tests/responses/user.json @@ -0,0 +1,30 @@ +{ + "userData": { + "email": "user@example.com", + "salutation": "MR", + "firstname": "James", + "surname": "Doe", + "address": { + "street": "Sesame street", + "zip": "12345", + "city": "Somewhere", + "country": "DE" + }, + "userId": 12345, + "createdOn": "2020-11-11T11:11:11.870Z" + }, + "lang": "de", + "role": "USER", + "active": true, + "pictureUrl": "https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/12345/picture", + "viewConfig": { + "facilitiesListView": "grid", + "serviceFacilitiesListView": "list", + "componentStartView": "component", + "facilitiesDashboardDefaultMode": "all", + "serviceFacilitiesDashboardDefaultMode": "all" + }, + "facilityCount": 2, + "permissions": [], + "temperatureUnit": "Celsius" +} diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 0000000..f8d0330 --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,89 @@ +"""Test the FACILITY endpoint.""" + +import pytest +from http import HTTPStatus +from aioresponses import aioresponses +from froeling import Froeling, endpoints + + +@pytest.mark.asyncio +async def test_facility_get_components(load_json): + facility_data = load_json('facility.json') + component_list_data = load_json('component_list.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + with aioresponses() as m: + m.get(endpoints.FACILITY.format(1234), status=200, payload=facility_data) + m.get( + endpoints.COMPONENT_LIST.format(1234, 12345), + status=200, + payload=component_list_data, + ) + + async with Froeling(token=token) as api: + f = await api.get_facility(12345) + c = await f.get_components() + assert len(c) == 5 + c = c[0] + + assert c.component_id == '1_100' + assert c.display_name == 'some display name' + assert c.display_category == 'Kessel' + assert c.standard_name == 'Kessel' + assert c.type == 'BOILER' + assert c.sub_type == 'WOODCHIP' + + +@pytest.mark.asyncio +async def test_component_update(load_json): + component_data = load_json('component.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + with aioresponses() as m: + m.get( + endpoints.COMPONENT.format(1234, 12345, '1_100'), + status=200, + payload=component_data, + ) + + async with Froeling(token=token) as api: + c = api.get_component(12345, '1_100') + await c.update() + for p in c.parameters.values(): + p.display_value + # TODO: Add asserts + + +@pytest.mark.asyncio +async def test_component_set_value(load_json): + component_data = load_json('component.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + with aioresponses() as m: + m.get( + endpoints.COMPONENT.format(1234, 12345, '1_100'), + status=200, + payload=component_data, + ) + m.put( + endpoints.SET_PARAMETER.format(1234, 12345, '3_0'), + status=HTTPStatus.NOT_MODIFIED, + payload='successmessage', + ) + m.put( + endpoints.SET_PARAMETER.format(1234, 12345, '3_0'), + status=200, + payload='successmessage', + ) + + async with Froeling(token=token) as api: + c = api.get_component(12345, '1_100') + await c.update() + msg = await list(c.parameters.values())[0].set_value('testvalue') + assert msg is None + + msg = await list(c.parameters.values())[0].set_value('testvalue') + assert msg == 'successmessage' diff --git a/tests/test_facility.py b/tests/test_facility.py new file mode 100644 index 0000000..c10857b --- /dev/null +++ b/tests/test_facility.py @@ -0,0 +1,252 @@ +"""Test the Facility class.""" + +import pytest +from aioresponses import aioresponses +from froeling import Froeling, endpoints + + +@pytest.mark.asyncio +async def test_get_facility(load_json): + facility_data = load_json('facility.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + with aioresponses() as m: + m.get(endpoints.FACILITY.format(1234), status=200, payload=facility_data) + + async with Froeling(token=token) as api: + f = await api.get_facilities() + + assert len(f) == 2 + f1, f2 = f + + assert f1.facility_id == 12345 + assert f1.equipment_number == 100321123 + assert f1.status == 'OK' + assert f1.name == 'Facility name' + assert f1.address.street == 'some street' + assert f1.address.zip == '3210' + assert f1.address.city == 'somewhere' + assert f1.address.country == 'DE' + assert f1.owner == 'Jimmy Smith' + assert f1.role == 'OWNER' + assert f1.favorite is False + assert f1.allow_messages is True + assert f1.subscribed_notifications is True + assert ( + f1.picture_url + == 'https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/1234/facility/12345/picture' + ) + assert f1.protocol_3200_info['hoursSinceLastMaintenance'] == '1000' + assert f1.protocol_3200_info['operationHours'] == '3000' + assert f1.protocol_3200_info['active'] is False + assert f1.protocol_3200_info['productType'] == 'T4e 230-250' + assert f1.protocol_3200_info['status'] == 'OK' + assert f1.facility_generation == 'GEN_3200' + + assert f2.facility_id == 54321 + assert f2.equipment_number == 100123321 + assert f2.status == 'OK' + assert f2.name == 'Another facility name' + assert f2.address.street == 'some street' + assert f2.address.zip == '3210' + assert f2.address.city == 'somewhere' + assert f2.address.country == 'DE' + assert f2.owner == 'Heisenberg' + assert f2.role == 'OWNER' + assert f2.favorite is False + assert f2.allow_messages is True + assert f2.subscribed_notifications is True + assert ( + f2.picture_url + == 'https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/1234/facility/12345/picture' + ) + assert f2.protocol_3200_info['hoursSinceLastMaintenance'] == '2000' + assert f2.protocol_3200_info['operationHours'] == '200' + assert f2.protocol_3200_info['active'] is False + assert f2.protocol_3200_info['productType'] == 'S4 Turbo 60' + assert f2.protocol_3200_info['status'] == 'OK' + assert f2.facility_generation == 'GEN_3200' + + +@pytest.mark.asyncio +async def test_get_facility_modified(load_json): + facility_data = load_json('facility_modified.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + with aioresponses() as m: + m.get(endpoints.FACILITY.format(1234), status=200, payload=facility_data) + + async with Froeling(token=token) as api: + f = await api.get_facilities() + assert len(f) == 3 + f1, f2, f3 = f + + assert f1.facility_id == 12345 + assert f1.equipment_number is None + assert f1.status == 'OK' + assert f1.name == 'Facility name' + assert f1.address.street == 'some street' + assert f1.address.zip == 'ABCD' + assert f1.address.city == 'somewhere' + assert f1.address.country == 'DE' + assert f1.owner == 'Jimmy Smith' + assert f1.role == 'OWNER' + assert f1.favorite is False + assert f1.allow_messages is True + assert f1.subscribed_notifications is True + assert f1.picture_url is None + assert f1.protocol_3200_info is None + assert f1.facility_generation == 'GEN_500' + + assert f2.facility_id == 17 + assert f2.equipment_number == 100123321 + assert f2.status == 'OK' + assert f2.name == 'Another facility name' + assert f2.address.street is None + assert f2.address.zip is None + assert f2.address.city is None + assert f2.address.country is None + assert f2.owner == 'Slippy Jim' + assert f2.role == 'OWNER' + assert f2.favorite is False + assert f2.allow_messages is True + assert f2.subscribed_notifications is True + assert ( + f2.picture_url + == 'https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/1234/facility/12345/picture' + ) + assert f2.protocol_3200_info['hoursSinceLastMaintenance'] == '2000' + assert f2.protocol_3200_info['operationHours'] == '200' + assert f2.protocol_3200_info['active'] is False + assert f2.protocol_3200_info['productType'] == 'AMG GT 63' + assert f2.protocol_3200_info['status'] == 'BAD' + assert f2.facility_generation == 'GEN_3200' + + assert f3.facility_id == 0 + assert f3.equipment_number == -567 + assert f3.status == 'NOTOK' + assert f3.name == 'Another facility name' + assert f3.address is None + assert f3.owner == 'Slippy Jim' + assert f3.role == 'OWNER' + assert f3.favorite is False + assert f3.allow_messages is True + assert f3.subscribed_notifications is True + assert ( + f3.picture_url + == 'https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/1234/facility/12345/picture' + ) + assert f3.protocol_3200_info is None + assert f3.facility_generation == 'GEN_6400' + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'component_id,expected', + [ + ( + '1_100', + { + 'display_name': 'some display name', + 'display_category': 'Kessel', + 'standard_name': 'Kessel', + 'type': 'BOILER', + 'sub_type': 'WOODCHIP', + }, + ), + ( + '300_3100', + { + 'display_name': 'Circuit 1', + 'display_category': 'Heizkreis', + 'standard_name': 'Heizkreis 01', + 'type': 'CIRCUIT', + 'sub_type': 'OUT_TEMP_CRTL', + }, + ), + ( + '300_3110', + { + 'display_name': 'Circuit 2', + 'display_category': 'Heizkreis', + 'standard_name': 'Heizkreis 02', + 'type': 'CIRCUIT', + 'sub_type': 'OUT_TEMP_CRTL', + }, + ), + ( + '200_2100', + { + 'display_name': 'Boiler 01', + 'display_category': 'Boiler', + 'standard_name': 'Boiler 01', + 'type': 'DHW', + 'sub_type': None, + }, + ), + ( + '400_4100', + { + 'display_name': 'Puffer 01', + 'display_category': 'Puffer', + 'standard_name': 'Puffer 01', + 'type': 'BUFFER_TANK', + 'sub_type': 'NEW_GENERATION', + }, + ), + ], +) +async def test_facility_get_components(load_json, component_id, expected): + facility_data = load_json('facility.json') + component_list_data = load_json('component_list.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + with aioresponses() as m: + m.get(endpoints.FACILITY.format(1234), status=200, payload=facility_data) + m.get( + endpoints.COMPONENT_LIST.format(1234, 12345), + status=200, + payload=component_list_data, + ) + + async with Froeling(token=token) as api: + facilities = await api.get_facilities() + components = await facilities[0].get_components() + + comp = next(c for c in components if c.component_id == component_id) + + for field, value in expected.items(): + assert getattr(comp, field) == value + assert comp.time_windows_view is None + assert comp.picture_url is None + assert comp.parameters == {} + + +@pytest.mark.asyncio +async def test_facility_get_component(load_json): + facility_data = load_json('facility.json') + component_data = load_json('component.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + with aioresponses() as m: + m.get(endpoints.FACILITY.format(1234), status=200, payload=facility_data) + m.get( + endpoints.COMPONENT.format(1234, 12345, '1_100'), + status=200, + payload=component_data, + ) + m.get( + endpoints.COMPONENT.format(1234, 12345, '1_100'), + status=200, + payload=component_data, + ) + + async with Froeling(token=token) as api: + f = await api.get_facility(12345) + c = f.get_component('1_100') + c2 = api.get_component(12345, '1_100') + assert c.component_id == c2.component_id diff --git a/tests/test_login.py b/tests/test_login.py new file mode 100644 index 0000000..a209107 --- /dev/null +++ b/tests/test_login.py @@ -0,0 +1,85 @@ +"""Tests the login process and USER endpoint.""" + +from unittest.mock import Mock + +import pytest +from aioresponses import aioresponses +from froeling import Froeling, endpoints, exceptions + + +@pytest.mark.asyncio +async def test_login_success(load_json): + login_data = load_json('login.json') + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + with aioresponses() as m: + m.post( + endpoints.LOGIN, + status=200, + payload=login_data, + headers={'Authorization': token}, + ) + + async with Froeling(username='joe', password='pwd') as api: + userdata = await api.get_userdata() # should be cached. No new requests + assert api.token == token + assert api.user_id == 1234 + + assert userdata.email == 'user@example.com' + assert userdata.salutation == 'MR' + assert userdata.firstname == 'James' + assert userdata.surname == 'Doe' + assert userdata.lang == 'de' + assert userdata.role == 'USER' + assert userdata.active is True + assert ( + userdata.picture_url + == 'https://connect-api.froeling.com/aks/connect/v1.0/resources/service/user/12345/picture' + ) + assert userdata.facility_count is None + + assert userdata.address.street == 'Sesame street' + assert userdata.address.zip == '12345' + assert userdata.address.city == 'Somewhere' + assert userdata.address.country == 'DE' + + +@pytest.mark.asyncio +async def test_login_failure_raises(load_json): + with aioresponses() as m: + m.post(endpoints.LOGIN, status=401, payload=load_json('login_bad_creds.json')) + + with pytest.raises(exceptions.AuthenticationError): + async with Froeling(username='joe', password='pwd'): + pass + + +@pytest.mark.asyncio +async def test_request_auto_reauth(load_json): + login_data = load_json('login.json') + user_data = load_json('user.json') + old_token = 'old.eyJ1c2VySWQiOjEyMzR9.signature' + new_token = 'new.eyJ1c2VySWQiOjEyMzR9.signature' + + mock_token_callback = Mock() + + with aioresponses() as m: + m.get(endpoints.USER.format(1234), status=401, body='security check failed') + m.post( + endpoints.LOGIN, + status=200, + payload=login_data, + headers={'Authorization': new_token}, + ) + m.get(endpoints.USER.format(1234), status=200, payload=user_data) + + async with Froeling( + username='joe', + password='pwd', + token=old_token, + auto_reauth=True, + token_callback=mock_token_callback, + ) as api: + await api.get_userdata() + + mock_token_callback.assert_called_once_with(new_token) diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..21a4dfd --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,125 @@ +"""Test notifications.""" + +import pytest +from aioresponses import aioresponses +import datetime +from froeling import Froeling, endpoints +from froeling.datamodels.notifications import NotificationSubmissionState + + +@pytest.mark.asyncio +async def test_get_notification_count(load_json): + notification_count_data = load_json('notification_count.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + with aioresponses() as m: + m.get( + endpoints.NOTIFICATION_COUNT.format(1234), + status=200, + payload=notification_count_data, + ) + + async with Froeling(token=token) as api: + count = await api.get_notification_count() + assert count == 123 + + +@pytest.mark.asyncio +async def test_get_notifications(load_json): + notification_list_data = load_json('notification_list.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + date = datetime.datetime( + 2025, 1, 2, 12, 34, 56, 789000, tzinfo=datetime.timezone.utc + ) + + with aioresponses() as m: + m.get( + endpoints.NOTIFICATION_LIST.format(1234), + status=200, + payload=notification_list_data, + ) + + async with Froeling(token=token) as api: + notifications = await api.get_notifications() + assert len(notifications) == 3 + for i, n in enumerate(notifications): + assert n.id == (i + 1) * 10000000 + 123456 + assert n.subject == f'Subject {i + 1}' + assert n.unread == (False, True, None)[i] + assert n.date == date + assert n.error_id == (None, 0, 404)[i] + assert n.type == ('INFO', 'ALARM', 'ERROR')[i] + assert n.facility_id == (12345, None, None)[i] + assert n.facility_name == ('F1', None, None)[i] + + +@pytest.mark.asyncio +async def test_get_notification_info(load_json): + notification_list_data = load_json('notification_list.json') + notification_data = load_json('notification.json') + + token = 'header.eyJ1c2VySWQiOjEyMzR9.signature' + + date = datetime.datetime( + 2025, 1, 2, 12, 34, 56, 789000, tzinfo=datetime.timezone.utc + ) + + with aioresponses() as m: + m.get( + endpoints.NOTIFICATION_LIST.format(1234), + status=200, + payload=notification_list_data, + ) + m.get( + endpoints.NOTIFICATION.format(1234, 10123456), + status=200, + payload=notification_data, + ) + m.get( + endpoints.NOTIFICATION.format(1234, 10123456), + status=200, + payload=notification_data, + ) + + async with Froeling(token=token) as api: + notification = (await api.get_notifications())[0] + notification_details = await notification.info() + assert notification.details == notification_details + + notification_details_2 = await api.get_notification(10123456) + assert notification_details == notification_details_2 + + d = notification_details + assert d.id == 10123456 + assert d.subject == 'Subject 1' + assert d.body == 'Title\r\ntext' + assert not d.sms + assert d.mail + assert d.push + assert not d.unread + assert d.date == date + assert d.type == 'INFO' + assert d.facility_id == 12345 + assert d.facility_name == 'F1' + assert len(d.notification_submission_state_dto) == 2 + s1, s2 = d.notification_submission_state_dto + + assert isinstance(s1, NotificationSubmissionState) + assert s1.id == 12345678 + assert s1.recipient == 'joe@example.com' + assert s1.type == 'EMAIL' + assert s1.submitted_to == 'joe@example.com' + assert s1.submission_result == 'SUCCESS' + + assert isinstance(s2, NotificationSubmissionState) + assert s2.id == 12345679 + assert s2.recipient == 'sometoken' + assert s2.type == 'TOKEN' + assert s2.submitted_to == 'joe@example.com' + assert s2.submission_result == 'SUCCESS' + + +# TODO: Test NotificationErrorSolution From 2138eac1b1508c161593b6929c6b99eb90716218 Mon Sep 17 00:00:00 2001 From: Layf21 <93056040+Layf21@users.noreply.github.com> Date: Fri, 14 Nov 2025 21:50:05 +0100 Subject: [PATCH 07/12] Add CI workflow --- .github/workflows/ci.yaml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..ea18dfa --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [ "dev", "main", "master" ] + pull_request: + branches: [ "dev", "main", "master" ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: | + 3.10 + 3.11 + 3.12 + 3.13 + - name: Pip cache + if: runner.os == 'Linux' + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: ${{ runner.os }}-pip- + - name: Install Hatch + run: pip install hatch + - name: Run ruff fmt + run: hatch fmt --check + - name: Run mypy + run: hatch run dev:mypy src + - name: Run tests + run: hatch test -a + - name: Test build + run: hatch build + From c126ff16f7c7effe05bcd39c270804b15d0677cc Mon Sep 17 00:00:00 2001 From: Layf21 <93056040+Layf21@users.noreply.github.com> Date: Sun, 16 Nov 2025 11:59:53 +0100 Subject: [PATCH 08/12] Update README.md --- README.md | 100 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b681f48..0bbb3f6 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,97 @@ # froeling-connect -An inofficial asynchronous implementation of the proprietary [fröling-connect](https://connect-web.froeling.com/) web API. +[![PyPI Version](https://img.shields.io/pypi/v/froeling-connect)](https://pypi.org/project/froeling-connect/) +[![PyPI Version](https://img.shields.io/pypi/v/froeling-connect)](https://pypi.org/project/froeling-connect/) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/froeling-connect) +![Development Status: Beta](https://img.shields.io/badge/development%20status-beta-orange) +![Build Status](https://github.com/Layf21/froeling-connect/actions/workflows/ci.yaml/badge.svg) +[![License](https://img.shields.io/pypi/l/froeling-connect)](https://github.com/Layf21/froeling-connect/blob/main/LICENSE.txt) +[![CodeFactor](https://www.codefactor.io/repository/github/layf21/froeling-connect/badge/main)](https://www.codefactor.io/repository/github/layf21/froeling-connect/overview/main) -## Disclaimer ->This library was only tested with the T4e Boiler, it may not work perfectly for other Machines. ->As this API is not public, there may be breaking changes on the backend. ->### I am not affiliated, associated, authorized, endorsed by, or in any way officially connected with Fröling Heizkessel- und Behälterbau Ges.m.b.H. ->Their official website can be found at https://www.froeling.com. +An **unofficial asynchronous Python library** for interacting with the proprietary [Fröling Connect](https://connect-web.froeling.com/) web API. + +> ⚠️ This library was primarily tested with the **T4e Boiler**. It may not work reliably with other models. +> +> This project is **not affiliated with Fröling Heizkessel- und Behälterbau Ges.m.b.H.**.
+> This library is provided "as is" and comes with **no warranty**. +Use at your own risk. The author is **not responsible for any damages**, including but not limited to equipment damage, fire, water damage, or data loss, resulting from the use of this software. + +--- ## Features -* Read notifications -* Get general information about facilities and components managed by the user -* Get and set parameters (not tested for all parameters) + +- Read notifications from Fröling Connect +- Retrieve general information about facilities and components +- Get and set parameters for components (partial support; not all parameters tested) +- Fully asynchronous API calls + +--- ## Installation -```py -m pip install froeling-connect``` +```bash +pip install froeling-connect +``` + +--- ## Terminology -|Name | Description | Examples | -|----------|---------------------------------------------------------------------------|---------------------------| -|Facility | The Heating-Installation. One User can manage multiple Facilities. | Wood Chip Boiler T4e | -|Component | A facility consists of multiple Components. | Boiler, Heating circuit | -|Parameter | Components have multiple parameters. These are measurements and settings. | Boiler State, Water Temp. | +| Name | Description | Examples | +| --------- | ------------------------------------------------------------------------- | ------------------------- | +| Facility | A heating installation. One user can manage multiple facilities. | Wood Chip Boiler T4e | +| Component | A facility consists of multiple components. | Boiler, Heating circuit | +| Parameter | Components have multiple parameters, including measurements and settings. | Boiler State, Water Temp. | +--- ## Usage -There is no documentation currently. -Example usage can be found [here](https://github.com/Layf21/froeling-connect/blob/master/example.py) + +Currently, there is no detailed documentation. +You can see a working example [here](https://github.com/Layf21/froeling-connect/blob/main/example.py). + +A tiny snippet to showcase some features: +```python +import asyncio +from froeling import Froeling + +async def main(): + async with Froeling("username", "password") as api: + facilities = await api.get_facilities() + for facility in facilities: + print(facility) + +asyncio.run(main()) +``` + +--- + +## Notes + +* The API is **not public**, so breaking changes on Fröling's end may occur without notice. +* This Project is still in beta; breaking changes are to be expected, though I try to minimize them. +* Contributions and bug reports are welcome. + +--- + +## Contributing + +1. Fork the repository +2. Create a new branch under feature/* +3. Submit a pull request into dev with your improvements + +This project uses [Hatch](https://hatch.pypa.io/). + +```sh +hatch fmt # Run linter and format code +hatch run dev:mypy src # Run mypy type checks +hatch test # Run tests (-a for all python versions) +``` + +--- + +## License + +[Apache License](https://github.com/Layf21/froeling-connect/blob/main/LICENSE.txt) + +--- From 648aecac6092f45f76e94733596fd124373e3b40 Mon Sep 17 00:00:00 2001 From: Layf21 <93056040+Layf21@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:20:14 +0100 Subject: [PATCH 09/12] Switch to dynamic versioning --- pyproject.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c5e79d1..da7a248 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "froeling-connect" -version = "0.1.3" +dynamic = ["version"] description = "A python wrapper for the Fröling-Connect API" readme = "README.md" requires-python = ">=3.10" -license = {file = "LICENSE.txt"} +license = {text = "Apache-2.0"} keywords = ["froeling", "fröling", "fröling-connect", "fröling connect"] authors = [ {name = "Layf"}, @@ -41,9 +41,12 @@ issues = "https://github.com/Layf21/froeling-connect/issues" [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +[tool.hatch.version] +source = "vcs" + [tool.hatch.build.targets.sdist] exclude = [ "/.github" From 944af2f22a41555376abbfe4d6f06000d6be1de9 Mon Sep 17 00:00:00 2001 From: Layf21 <93056040+Layf21@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:21:02 +0100 Subject: [PATCH 10/12] Remove unneeded requirements.txt --- requirements.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9fe29a9..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -aiohttp==3.8.4 From 29306d6c9fbbba0d927bffcc5d84f409f60d378a Mon Sep 17 00:00:00 2001 From: Layf21 <93056040+Layf21@users.noreply.github.com> Date: Sun, 16 Nov 2025 13:09:08 +0100 Subject: [PATCH 11/12] Overhaul example.py --- example.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/example.py b/example.py index 3633e95..f0eec3f 100644 --- a/example.py +++ b/example.py @@ -15,10 +15,8 @@ password = input('Password : ') -def print_new_token( - token, -): # Gets executed when a new token was created (useful for storing the token for next time the program is run) - print('The new token ist:', token) +def print_new_token(token): # Gets executed when a new token was created (useful for storing the token for next time the program is run) + print(f'The new token is: {token}') async def main(): @@ -35,12 +33,8 @@ async def main(): token_callback=print_new_token, ) as client: for notification in (await client.get_notifications())[:3]: # Fetch notifications - await ( - notification.info() - ) # Load more information about one of the notifications - print( - f'\n[Notification {notification.id}] Subject: {notification.subject}\n{notification.details.body}\n\n' - ) + await notification.info() # Load more information about one of the notifications + print(f'\n[Notification {notification.id}] Subject: {notification.subject}\n{notification.details.body}\n\n') facility = (await client.get_facilities())[0] # Get a list of all facilities print(facility) @@ -49,19 +43,15 @@ async def main(): example_component = (await facility.get_components())[0] print(example_component) - await ( - example_component.update() - ) # Get more information about the component. This includes the parameters. + await example_component.update() # Get more information about the component. This includes the parameters. print(f'{example_component.type} {example_component.sub_type}: {example_component.display_name} \n{"_" * 20}') - for parameter in ( - example_component.parameters.values() - ): # Loop over all data af the component + for parameter in (example_component.parameters.values()): # Loop over all parameters of the component print(parameter.display_name, ':', parameter.display_value) # You can directly reference a component of a facility by its id example_component2 = facility.get_component('1_100') - await example_component2.update() # The update method is required to fully populete the component's data. - print('\n\nExample Component:', example_component2.display_name) + await example_component2.update() # The update method is required to fully populate the component's data. + print(f'\n\nExample Component: {example_component2.display_name}') param = example_component2.parameters.get('7_28') if param: From 963568f4bd5eff1506a6510171b2154ee3d80db4 Mon Sep 17 00:00:00 2001 From: Layf21 <93056040+Layf21@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:33:53 +0100 Subject: [PATCH 12/12] Add tagging and publishing system - Added bump-version workflow - Added publish workflow - Set fallback-version for hatch-vcs to 0.0.0 - Remove master branch from ci workflow --- .github/workflows/bump-version.yaml | 27 ++++++++++++++++++++++ .github/workflows/ci.yaml | 4 ++-- .github/workflows/publish.yaml | 36 +++++++++++++++++++++++++++++ pyproject.toml | 1 + 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/bump-version.yaml create mode 100644 .github/workflows/publish.yaml diff --git a/.github/workflows/bump-version.yaml b/.github/workflows/bump-version.yaml new file mode 100644 index 0000000..29d6d42 --- /dev/null +++ b/.github/workflows/bump-version.yaml @@ -0,0 +1,27 @@ +name: Bump version +on: + pull_request: + types: + - closed + branches: + - main + +jobs: + build: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: '0' + + - name: Bump version and push tag + uses: anothrNick/github-tag-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_PREFIX: v + PRERELEASE: false + DEFAULT_BUMP: none diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ea18dfa..6ead2ae 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ "dev", "main", "master" ] + branches: [ "dev", "main" ] pull_request: - branches: [ "dev", "main", "master" ] + branches: [ "dev", "main" ] jobs: test: diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..db29544 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,36 @@ +name: "Publish to PyPi" + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + environment: release + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.13' + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install hatch + - name: Build package + run: hatch build + - name: Test package + run: hatch run test + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/pyproject.toml b/pyproject.toml index da7a248..c0e9bdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" +fallback-version = "0.0.0" [tool.hatch.build.targets.sdist] exclude = [