From e8fb372464eb97ff7795190f29148b11c3e44ea9 Mon Sep 17 00:00:00 2001 From: Andrzej Chmielewski Date: Wed, 22 Jun 2022 23:29:14 +0200 Subject: [PATCH] initial release --- .devcontainer/configuration.yaml | 8 + .devcontainer/devcontainer.json | 28 +++ .github/ISSUE_TEMPLATE/feature_request.md | 16 ++ .github/ISSUE_TEMPLATE/issue.md | 40 +++++ .github/dependabot.yml | 14 ++ .github/labels.yml | 66 +++++++ .github/release-drafter.yml | 29 +++ .github/workflows/constraints.txt | 5 + .github/workflows/labeler.yml | 20 +++ .github/workflows/release-drafter.yml | 15 ++ .github/workflows/tests.yaml | 85 +++++++++ .gitignore | 168 ++++++++++++++++++ .pre-commit-config.yaml | 26 +++ .vscode/launch.json | 34 ++++ .vscode/settings.json | 8 + .vscode/tasks.json | 29 +++ CONTRIBUTING.md | 105 +++++++++++ LICENSE | 21 +++ README.md | 54 ++++++ custom_components/__init__.py | 1 + custom_components/ha_tedee_lock/__init__.py | 122 +++++++++++++ custom_components/ha_tedee_lock/api.py | 128 +++++++++++++ .../ha_tedee_lock/binary_sensor.py | 59 ++++++ .../ha_tedee_lock/config_flow.py | 98 ++++++++++ custom_components/ha_tedee_lock/const.py | 36 ++++ .../ha_tedee_lock/data_update_coordinator.py | 41 +++++ custom_components/ha_tedee_lock/lock.py | 99 +++++++++++ custom_components/ha_tedee_lock/manifest.json | 12 ++ .../ha_tedee_lock/model/__init__.py | 0 .../ha_tedee_lock/model/device_type.py | 7 + .../ha_tedee_lock/model/devices/__init__.py | 0 .../ha_tedee_lock/model/devices/bridge.py | 97 ++++++++++ .../ha_tedee_lock/model/devices/device.py | 41 +++++ .../ha_tedee_lock/model/devices/keypad.py | 124 +++++++++++++ .../ha_tedee_lock/model/devices/lock.py | 121 +++++++++++++ .../ha_tedee_lock/model/model_utils.py | 58 ++++++ .../ha_tedee_lock/model/operation_result.py | 26 +++ .../ha_tedee_lock/model/responses/__init__.py | 0 .../model/responses/my_bridge_response.py | 21 +++ .../model/responses/my_keypad_response.py | 21 +++ .../model/responses/my_lock_response.py | 21 +++ .../model/responses/my_lock_sync_response.py | 21 +++ .../model/responses/operation_response.py | 37 ++++ .../model/responses/response_metadata.py | 38 ++++ .../ha_tedee_lock/model/states/__init__.py | 0 .../model/states/device_state.py | 23 +++ .../model/states/device_state_lock.py | 44 +++++ custom_components/ha_tedee_lock/sensor.py | 61 +++++++ .../ha_tedee_lock/translations/en.json | 35 ++++ .../ha_tedee_lock/translations/pl.json | 35 ++++ hacs.json | 6 + info.md | 59 ++++++ pylintrc | 3 + requirements.txt | 0 requirements_dev.txt | 1 + requirements_test.txt | 3 + setup.cfg | 46 +++++ tests/__init__.py | 1 + tests/conftest.py | 45 +++++ tests/const.py | 94 ++++++++++ tests/test_api.py | 67 +++++++ tests/test_config_flow.py | 102 +++++++++++ tests/test_init.py | 55 ++++++ 63 files changed, 2680 insertions(+) create mode 100644 .devcontainer/configuration.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/issue.md create mode 100644 .github/dependabot.yml create mode 100644 .github/labels.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/constraints.txt create mode 100644 .github/workflows/labeler.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 .github/workflows/tests.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 custom_components/__init__.py create mode 100644 custom_components/ha_tedee_lock/__init__.py create mode 100644 custom_components/ha_tedee_lock/api.py create mode 100644 custom_components/ha_tedee_lock/binary_sensor.py create mode 100644 custom_components/ha_tedee_lock/config_flow.py create mode 100644 custom_components/ha_tedee_lock/const.py create mode 100644 custom_components/ha_tedee_lock/data_update_coordinator.py create mode 100644 custom_components/ha_tedee_lock/lock.py create mode 100644 custom_components/ha_tedee_lock/manifest.json create mode 100644 custom_components/ha_tedee_lock/model/__init__.py create mode 100644 custom_components/ha_tedee_lock/model/device_type.py create mode 100644 custom_components/ha_tedee_lock/model/devices/__init__.py create mode 100644 custom_components/ha_tedee_lock/model/devices/bridge.py create mode 100644 custom_components/ha_tedee_lock/model/devices/device.py create mode 100644 custom_components/ha_tedee_lock/model/devices/keypad.py create mode 100644 custom_components/ha_tedee_lock/model/devices/lock.py create mode 100644 custom_components/ha_tedee_lock/model/model_utils.py create mode 100644 custom_components/ha_tedee_lock/model/operation_result.py create mode 100644 custom_components/ha_tedee_lock/model/responses/__init__.py create mode 100644 custom_components/ha_tedee_lock/model/responses/my_bridge_response.py create mode 100644 custom_components/ha_tedee_lock/model/responses/my_keypad_response.py create mode 100644 custom_components/ha_tedee_lock/model/responses/my_lock_response.py create mode 100644 custom_components/ha_tedee_lock/model/responses/my_lock_sync_response.py create mode 100644 custom_components/ha_tedee_lock/model/responses/operation_response.py create mode 100644 custom_components/ha_tedee_lock/model/responses/response_metadata.py create mode 100644 custom_components/ha_tedee_lock/model/states/__init__.py create mode 100644 custom_components/ha_tedee_lock/model/states/device_state.py create mode 100644 custom_components/ha_tedee_lock/model/states/device_state_lock.py create mode 100644 custom_components/ha_tedee_lock/sensor.py create mode 100644 custom_components/ha_tedee_lock/translations/en.json create mode 100644 custom_components/ha_tedee_lock/translations/pl.json create mode 100644 hacs.json create mode 100644 info.md create mode 100644 pylintrc create mode 100644 requirements.txt create mode 100644 requirements_dev.txt create mode 100644 requirements_test.txt create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/const.py create mode 100644 tests/test_api.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_init.py diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..cfb00c1 --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,8 @@ +default_config: + +logger: + default: info + logs: + custom_components.ha_tedee_lock: debug +# If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +debugpy: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a605e6a --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ludeeus/container:integration-debian", + "name": "Tedee Lock integration development", + "context": "..", + "appPort": ["9123:8123"], + "postCreateCommand": "container install", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a09db44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,16 @@ +--- +name: Feature request +about: Suggest an idea for this project +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..218615e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,40 @@ +--- +name: Issue +about: Create a report to help us improve +--- + + + +## Version of the custom_component + + + +## Configuration + +```yaml +Add your logs here. +``` + +## Describe the bug + +A clear and concise description of what the bug is. + +## Debug log + + + +```text + +Add your logs here. + +``` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..15c7513 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + - package-ecosystem: pip + directory: "/.github/workflows" + schedule: + interval: daily + - package-ecosystem: pip + directory: "/" + schedule: + interval: daily diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..f7f83aa --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,66 @@ +--- +# Labels names are important as they are used by Release Drafter to decide +# regarding where to record them in changelog or if to skip them. +# +# The repository labels will be automatically configured using this file and +# the GitHub Action https://github.com/marketplace/actions/github-labeler. +- name: breaking + description: Breaking Changes + color: bfd4f2 +- name: bug + description: Something isn't working + color: d73a4a +- name: build + description: Build System and Dependencies + color: bfdadc +- name: ci + description: Continuous Integration + color: 4a97d6 +- name: dependencies + description: Pull requests that update a dependency file + color: 0366d6 +- name: documentation + description: Improvements or additions to documentation + color: 0075ca +- name: duplicate + description: This issue or pull request already exists + color: cfd3d7 +- name: enhancement + description: New feature or request + color: a2eeef +- name: github_actions + description: Pull requests that update Github_actions code + color: "000000" +- name: good first issue + description: Good for newcomers + color: 7057ff +- name: help wanted + description: Extra attention is needed + color: 008672 +- name: invalid + description: This doesn't seem right + color: e4e669 +- name: performance + description: Performance + color: "016175" +- name: python + description: Pull requests that update Python code + color: 2b67c6 +- name: question + description: Further information is requested + color: d876e3 +- name: refactoring + description: Refactoring + color: ef67c4 +- name: removal + description: Removals and Deprecations + color: 9ae7ea +- name: style + description: Style + color: c120e5 +- name: testing + description: Testing + color: b1fc6f +- name: wontfix + description: This will not be worked on + color: ffffff diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..7a04410 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,29 @@ +categories: + - title: ":boom: Breaking Changes" + label: "breaking" + - title: ":rocket: Features" + label: "enhancement" + - title: ":fire: Removals and Deprecations" + label: "removal" + - title: ":beetle: Fixes" + label: "bug" + - title: ":racehorse: Performance" + label: "performance" + - title: ":rotating_light: Testing" + label: "testing" + - title: ":construction_worker: Continuous Integration" + label: "ci" + - title: ":books: Documentation" + label: "documentation" + - title: ":hammer: Refactoring" + label: "refactoring" + - title: ":lipstick: Style" + label: "style" + - title: ":package: Dependencies" + labels: + - "dependencies" + - "build" +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt new file mode 100644 index 0000000..4f273b7 --- /dev/null +++ b/.github/workflows/constraints.txt @@ -0,0 +1,5 @@ +pip==22.1.2 +pre-commit==2.19.0 +black==22.3.0 +flake8==4.0.1 +reorder-python-imports==3.1.0 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..911e0b8 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,20 @@ +name: Manage labels + +on: + push: + branches: + - main + - master + +jobs: + labeler: + name: Labeler + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v3.0.2 + + - name: Run Labeler + uses: crazy-max/ghaction-github-labeler@v4.0.0 + with: + skip-delete: true diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..c0e27bf --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,15 @@ +name: Draft a release note +on: + push: + branches: + - main + - master +jobs: + draft_release: + name: Release Drafter + runs-on: ubuntu-latest + steps: + - name: Run release-drafter + uses: release-drafter/release-drafter@v5.20.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..9ecf001 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,85 @@ +name: Linting + +on: + push: + branches: + - main + - master + - dev + pull_request: + schedule: + - cron: "0 0 * * *" + +env: + DEFAULT_PYTHON: 3.9 + +jobs: + pre-commit: + runs-on: "ubuntu-latest" + name: Pre-commit + steps: + - name: Check out the repository + uses: actions/checkout@v3.0.2 + + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v4.0.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + + - name: Upgrade pip + run: | + pip install --constraint=.github/workflows/constraints.txt pip + pip --version + + - name: Install Python modules + run: | + pip install --constraint=.github/workflows/constraints.txt pre-commit black flake8 reorder-python-imports + + - name: Run pre-commit on all files + run: | + pre-commit run --all-files --show-diff-on-failure --color=always + + hacs: + runs-on: "ubuntu-latest" + name: HACS + steps: + - name: Check out the repository + uses: "actions/checkout@v3.0.2" + + - name: HACS validation + uses: "hacs/action@22.5.0" + with: + category: "integration" + + hassfest: + runs-on: "ubuntu-latest" + name: Hassfest + steps: + - name: Check out the repository + uses: "actions/checkout@v3.0.2" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v3.0.2" + - name: Setup Python ${{ env.DEFAULT_PYTHON }} + uses: "actions/setup-python@v4.0.0" + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Install requirements + run: | + pip install --constraint=.github/workflows/constraints.txt pip + pip install -r requirements_test.txt + - name: Tests suite + run: | + pytest \ + --timeout=9 \ + --durations=10 \ + -n auto \ + -p no:sugar \ + --cov-fail-under=78 \ + tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e57e592 --- /dev/null +++ b/.gitignore @@ -0,0 +1,168 @@ +__pycache__ +pythonenv* +.python-version +.coverage +venv +.venv + + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$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 + +# 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 + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.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/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..715622e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.3.0 + hooks: + - id: check-added-large-files + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: local + hooks: + - id: flake8 + name: flake8 + entry: flake8 + language: system + types: [python] + require_serial: true + - id: reorder-python-imports + name: Reorder python imports + entry: reorder-python-imports + language: system + types: [python] + args: [--application-directories=custom_components] + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.2.1 + hooks: + - id: prettier diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cc5337a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Example of attaching to local debug server + "name": "Python: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + // Example of attaching to my production server + "name": "Python: Attach Remote", + "type": "python", + "request": "attach", + "port": 5678, + "host": "homeassistant.local", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/usr/src/homeassistant" + } + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a18dc56 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.pythonPath": "venv/bin/python", + "files.associations": { + "*.yaml": "home-assistant" + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..47f1210 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 9123", + "type": "shell", + "command": "container start", + "problemMatcher": [] + }, + { + "label": "Run Home Assistant configuration against /config", + "type": "shell", + "command": "container check", + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "container install", + "problemMatcher": [] + }, + { + "label": "Install a specific version of Home Assistant", + "type": "shell", + "command": "container set-version", + "problemMatcher": [] + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2ef9796 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,105 @@ +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `master`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using black). +4. Test you contribution. +5. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People _love_ thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) +to make sure the code follows the style. + +Or use the `pre-commit` settings implemented in this repository +(see deicated section below). + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) +file. + +You can use the `pre-commit` settings implemented in this repository to have +linting tool checking your contributions (see deicated section below). + +You should also verify that existing [tests](./tests) are still working +and you are encouraged to add new ones. +You can run the tests using the following commands from the root folder: + +```bash +# Create a virtual environment +python3 -m venv venv +source venv/bin/activate +# Install requirements +pip install -r requirements_test.txt +# Run tests and get a summary of successes/failures and code coverage +pytest --durations=10 --cov-report term-missing --cov=custom_components.ha_tedee_lock tests +``` + +If any of the tests fail, make the necessary changes to the tests as part of +your changes to the integration. + +## Pre-commit + +You can use the [pre-commit](https://pre-commit.com/) settings included in the +repostory to have code style and linting checks. + +With `pre-commit` tool already installed, +activate the settings of the repository: + +```console +$ pre-commit install +``` + +Now the pre-commit tests will be done every time you commit. + +You can run the tests on all repository file with the command: + +```console +$ pre-commit run --all-files +``` + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b5148bc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 andrzejchm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..649ece5 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Tedee Lock + +[![GitHub Release][releases-shield]][releases] +[![License][license-shield]](LICENSE) +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +**This component will set up the following platforms.** + +| Platform | Description | +| --------------- | ------------------------------------------------------------------------------- | +| `lock` | Sets up Tedee Lock to be used as lock,allowing for opening and closing the lock | +| `sensor` | Sensor indicating the battery level of lock | +| `binary_sensor` | Binary sensor indicating whether the lock is charging or not | + +## Installation + +### Method 1 ([HACS](https://hacs.xyz/)) + +> HACS > Integrations > Plus > ha_tedee_lock > Install + +### Method 2 (Manual) + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +2. If you do not have a `custom_components` directory (folder) there, you need to create it. +3. In the `custom_components` directory (folder) create a new folder called `ha_tedee_lock`. +4. Download _all_ the files from the `custom_components/ha_tedee_lock/` directory (folder) in this repository. +5. Place the files you downloaded in the new directory (folder) you created. +6. Restart Home Assistant +7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Tedee Lock" + +## Configuration is done in the UI + + + +## Contributions are welcome! + +If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) + +## Credits + +This project was generated from [@oncleben31](https://github.com/oncleben31)'s [Home Assistant Custom Component Cookiecutter](https://github.com/oncleben31/cookiecutter-homeassistant-custom-component) template. + +Code template was mainly taken from [@Ludeeus](https://github.com/ludeeus)'s [integration_blueprint][integration_blueprint] template + +--- + +[buymecoffee]: https://www.buymeacoffee.com/andrzejchm +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge +[hacs]: https://hacs.xyz +[license-shield]: https://img.shields.io/github/license/andrzejchm/ha_tedee_lock.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-%40andrzejchm-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/andrzejchm/ha_tedee_lock.svg?style=for-the-badge +[releases]: https://github.com/andrzejchm/ha_tedee_lock/releases +[user_profile]: https://github.com/andrzejchm diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..9e5dc14 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Dummy init so that pytest works.""" diff --git a/custom_components/ha_tedee_lock/__init__.py b/custom_components/ha_tedee_lock/__init__.py new file mode 100644 index 0000000..31dc63f --- /dev/null +++ b/custom_components/ha_tedee_lock/__init__.py @@ -0,0 +1,122 @@ +""" +Custom integration to integrate Tedee Lock with Home Assistant. + +For more details about this integration, please refer to +https://github.com/andrzejchm/ha_tedee_lock +""" +import asyncio +import logging +from typing import Dict +from typing import Optional + +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import TedeeLockApiClient +from .const import CONF_DEVICE_INFO +from .const import CONF_DEVICE_TYPE +from .const import CONF_PERSONAL_ACCESS_TOKEN +from .const import DOMAIN +from .const import PLATFORMS +from .const import STARTUP_MESSAGE +from .data_update_coordinator import TedeeUpdateCoordinator +from .model.device_type import DeviceType +from .model.devices.device import Device +from .model.devices.lock import Lock + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up this integration using UI.""" + if hass.data.get(DOMAIN) is None: + _LOGGER.info(STARTUP_MESSAGE) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(entry.entry_id, {}) + token = entry.data.get(CONF_PERSONAL_ACCESS_TOKEN) + device_info_dict = entry.data.get(CONF_DEVICE_INFO) + device_type = DeviceType(entry.data.get(CONF_DEVICE_TYPE)) + session = async_get_clientsession(hass) + api_client = TedeeLockApiClient(token, session, hass) + coordinator = TedeeUpdateCoordinator( + hass=hass, + api=api_client, + ) + + device = device_from_dict(device_info_dict, device_type=device_type) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await _refresh_tedee_data(coordinator, device) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def _refresh_tedee_data( + coordinator: DataUpdateCoordinator, + device: Device, +): + try: + await coordinator.async_config_entry_first_refresh() + if not coordinator.data.get(device.data_key): + raise ConfigEntryNotReady( + f"missing device info for ({device.device_type}) id: {device.id}" + ) + except ConfigEntryNotReady as ex: + _LOGGER.exception(ex) + raise ex + except Exception as ex: + _LOGGER.exception(ex) + message = f"could not set up Tedee device: {ex}" + raise ConfigEntryNotReady(message) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id) + + return unloaded + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) + + +def access_token_schema( + user_input: Optional[Dict[str, any]] = None, +) -> vol.Schema: + """creates schema for config flow and options flow""" + return vol.Schema( + { + vol.Required( + CONF_PERSONAL_ACCESS_TOKEN, + default=(user_input or {}).get(CONF_PERSONAL_ACCESS_TOKEN), + ): str + } + ) + + +def device_from_dict( + device_info: dict, + device_type: DeviceType, +) -> Device: + if device_type == DeviceType.LOCK: + return Lock.from_dict(device_info) + else: + raise f"Could not parse device_info, unknown type: {device_type}" diff --git a/custom_components/ha_tedee_lock/api.py b/custom_components/ha_tedee_lock/api.py new file mode 100644 index 0000000..c0c9ac5 --- /dev/null +++ b/custom_components/ha_tedee_lock/api.py @@ -0,0 +1,128 @@ +"""Sample API Client.""" +import logging +from typing import List + +import aiohttp +from homeassistant.core import HomeAssistant + +from .const import API_BASE_URL +from .model.devices.bridge import Bridge +from .model.devices.device import Device +from .model.devices.keypad import Keypad +from .model.devices.lock import Lock +from .model.responses.my_bridge_response import MyBridgeResponse +from .model.responses.my_keypad_response import MyKeypadResponse +from .model.responses.my_lock_response import MyLockResponse +from .model.responses.my_lock_sync_response import MyLockSyncResponse +from .model.responses.operation_response import OperationResponse +from .model.states.device_state import DeviceState +from .model.states.device_state_lock import ( + DeviceStateLock, +) + +TIMEOUT = 10 + +_LOGGER: logging.Logger = logging.getLogger(__package__) + +HEADERS = {"Content-type": "application/json; charset=UTF-8"} + + +class TedeeLockApiClient: + """Api client for Tedee Lock""" + + def __init__( + self, + access_token: str, + session: aiohttp.ClientSession, + hass: HomeAssistant, + ) -> None: + """Sample API Client.""" + self._access_token = access_token + self._session = session + self._hass = hass + + async def async_get_devices_info(self) -> List[Device]: + locks = await self.async_get_locks() + # bridges = await self.async_get_bridges() TODO enable this when setting up bridges makes sense + # keypads = await self.async_get_keypads() TODO enable this when setting up keypads makes sense + return [ + *locks, + # *bridges, + # *keypads, + ] + + async def async_get_devices_states(self) -> List[DeviceState]: + """Returns all devices' states (locks for now, but in the future might return Keypad if the API supports it)""" + locks = await self.async_get_locks_states() + return [ + *locks, + ] + + async def async_get_locks(self) -> List[Lock]: + """Returns all locks' device infos""" + response = await self._session.get( + f"{API_BASE_URL}/api/v1.25/my/lock", + headers={**HEADERS, "Authorization": f"PersonalKey {self._access_token}"}, + ) + json = await response.json() + result = MyLockResponse.from_dict(json) + return result.result + + async def async_get_locks_states(self) -> List[DeviceStateLock]: + """Returns all locks' states""" + response = await self._session.get( + f"{API_BASE_URL}/api/v1.25/my/lock/sync", + headers={ + **HEADERS, + "Authorization": f"PersonalKey {self._access_token}", + }, + ) + json = await response.json() + result = MyLockSyncResponse.from_dict(json) + return result.result + + async def async_get_bridges(self) -> List[Bridge]: + """Returns all bridges' device infos""" + response = await self._session.get( + f"{API_BASE_URL}/api/v1.25/my/bridge", + headers={**HEADERS, "Authorization": f"PersonalKey {self._access_token}"}, + ) + json = await response.json() + result = MyBridgeResponse.from_dict(json) + return result.result + pass + + async def async_get_keypads(self) -> List[Keypad]: + """Returns all bridges' device infos""" + response = await self._session.get( + f"{API_BASE_URL}/api/v1.25/my/keypad", + headers={**HEADERS, "Authorization": f"PersonalKey {self._access_token}"}, + ) + json = await response.json() + result = MyKeypadResponse.from_dict(json) + return result.result + pass + + async def async_operation_lock(self, lock_id: int) -> OperationResponse: + """Locks given lock""" + response = await self._session.post( + f"{API_BASE_URL}/api/v1.25/my/lock/{lock_id}/operation/lock", + headers={ + **HEADERS, + "Authorization": f"PersonalKey {self._access_token}", + }, + ) + json = await response.json() + return OperationResponse.from_dict(json) + + async def async_operation_unlock(self, lock_id: int) -> OperationResponse: + """Unlocks given lock""" + response = await self._session.post( + f"{API_BASE_URL}/api/v1.25/my/lock/{lock_id}/operation/unlock", + headers={ + **HEADERS, + "Authorization": f"PersonalKey {self._access_token}", + }, + ) + json = await response.json() + return OperationResponse.from_dict(json) diff --git a/custom_components/ha_tedee_lock/binary_sensor.py b/custom_components/ha_tedee_lock/binary_sensor.py new file mode 100644 index 0000000..f32aa77 --- /dev/null +++ b/custom_components/ha_tedee_lock/binary_sensor.py @@ -0,0 +1,59 @@ +from typing import Optional + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import BinarySensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_DEVICE_INFO +from .const import CONF_DEVICE_TYPE +from .const import DOMAIN +from .model.device_type import DeviceType +from .model.devices.lock import Lock +from .model.states.device_state_lock import DeviceStateLock + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + device_dict: dict = entry.data[CONF_DEVICE_INFO] + device_type = DeviceType(entry.data[CONF_DEVICE_TYPE]) + coordinator = hass.data[DOMAIN][entry.entry_id] + if device_type == DeviceType.LOCK: + async_add_entities([ + LockIsChargingBinarySensor( + lock=Lock.from_dict(device_dict), + coordinator=coordinator, + ) + ]) + pass + + +class LockIsChargingBinarySensor(CoordinatorEntity, BinarySensorEntity): + + def __init__( + self, + lock: Lock, + coordinator: DataUpdateCoordinator, + ): + super().__init__(coordinator) + self._lock = lock + self.entity_description = BinarySensorEntityDescription( + key="lock_is_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + ) + self._attr_unique_id = f"tedee-lock-{lock.name}-{lock.id}-is-charging" + self._attr_name = f"{lock.name} lock is charging" + + @property + def is_on(self) -> Optional[bool]: + return self._lock_state().lock_properties.is_charging + + def _lock_state(self) -> Optional[DeviceStateLock]: + return self.coordinator.data[self._lock.data_key] diff --git a/custom_components/ha_tedee_lock/config_flow.py b/custom_components/ha_tedee_lock/config_flow.py new file mode 100644 index 0000000..b91a81c --- /dev/null +++ b/custom_components/ha_tedee_lock/config_flow.py @@ -0,0 +1,98 @@ +"""Adds config flow for Tedee Lock.""" +import logging +from typing import List +from typing import Optional + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from . import access_token_schema +from . import TedeeLockApiClient +from .const import CONF_DEVICE_ID +from .const import CONF_DEVICE_INFO +from .const import CONF_DEVICE_TYPE +from .const import CONF_PERSONAL_ACCESS_TOKEN +from .const import DOMAIN +from .model.devices.device import Device + +_LOGGER = logging.getLogger(__name__) + + +class TedeeLockConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for ha_tedee_lock.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._errors = {} + self._devices: List[Device] = [] + self._access_token: Optional[str] = None + + async def async_step_user(self, user_input: dict = None): + """Handle a flow initialized by the user.""" + if user_input is not None: + self._access_token = user_input.get(CONF_PERSONAL_ACCESS_TOKEN) + try: + self._devices = await self._get_devices() + if self._devices: + return await self.async_step_select_device() + else: + self._errors = {"base": "no_devices_found"} + return self.async_show_form( + step_id="user", + data_schema=access_token_schema(), + errors=self._errors, + ) + except Exception as ex: + _LOGGER.exception(ex) + self._errors = {"base": "unknown_error"} + return self.async_show_form( + step_id="user", + data_schema=access_token_schema(user_input), + errors=self._errors, + ) + + else: + self._errors = {} + return self.async_show_form( + step_id="user", + data_schema=access_token_schema(user_input), + errors=self._errors, + ) + + async def async_step_select_device(self, user_input: dict = None): + """Shows and handles device selection form""" + + if user_input is not None: + device = self._find_device_by_list_name(user_input.get("selected_device")) + if device is not None: + return self.async_create_entry( + title=device.name, + description=str(device.device_type), + data={ + CONF_PERSONAL_ACCESS_TOKEN: self._access_token, + CONF_DEVICE_INFO: device.to_dict(), + CONF_DEVICE_TYPE: device.device_type.value, + CONF_DEVICE_ID: device.id, + }, + ) + else: + device_names = [device.list_name for device in self._devices] + schema = vol.Schema({vol.Required("selected_device"): vol.In(device_names)}) + self._errors = {} + return self.async_show_form( + step_id="select_device", + data_schema=schema, + errors=self._errors, + ) + + def _find_device_by_list_name(self, list_name: str) -> Optional[Device]: + return next(filter(lambda x: x.list_name == list_name, self._devices)) + + async def _get_devices(self) -> List[Device]: + session = async_create_clientsession(self.hass) + api = TedeeLockApiClient(self._access_token, session, self.hass) + return await api.async_get_devices_info() diff --git a/custom_components/ha_tedee_lock/const.py b/custom_components/ha_tedee_lock/const.py new file mode 100644 index 0000000..a1e9b3f --- /dev/null +++ b/custom_components/ha_tedee_lock/const.py @@ -0,0 +1,36 @@ +"""Constants for Tedee Lock.""" +# Base component constants +NAME = "Tedee Lock" +DOMAIN = "ha_tedee_lock" +DOMAIN_DATA = f"{DOMAIN}_data" +VERSION = "1.0.0" + +ISSUE_URL = "https://github.com/andrzejchm/ha_tedee_lock/issues" + +# Platforms +LOCK = "lock" +SENSOR = "sensor" +SENSOR = "sensor" +BINARY_SENSOR = "binary_sensor" +PLATFORMS = [LOCK, SENSOR, BINARY_SENSOR] + +# Configuration and options +CONF_PERSONAL_ACCESS_TOKEN = "personal_access_token" +CONF_DEVICE_INFO = "device_info" +CONF_DEVICE_TYPE = "device_type" +CONF_DEVICE_ID = "device_id" +API_BASE_URL = "https://api.tedee.com" + +# Defaults +DEFAULT_NAME = DOMAIN +DEFAULT_PORT = 80 + +STARTUP_MESSAGE = f""" +------------------------------------------------------------------- +{NAME} +Version: {VERSION} +This is a custom integration! +If you have any issues with this you need to open an issue here: +{ISSUE_URL} +------------------------------------------------------------------- +""" diff --git a/custom_components/ha_tedee_lock/data_update_coordinator.py b/custom_components/ha_tedee_lock/data_update_coordinator.py new file mode 100644 index 0000000..6aa3719 --- /dev/null +++ b/custom_components/ha_tedee_lock/data_update_coordinator.py @@ -0,0 +1,41 @@ +import logging +from datetime import timedelta +from typing import Tuple + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import TedeeLockApiClient +from .const import DOMAIN +from .model.states.device_state import DeviceState + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) + + +class TedeeUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceState]]): + def __init__( + self, + hass: HomeAssistant, + api: TedeeLockApiClient, + ): + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=DOMAIN, + # Polling interval. Will only be polled if there are subscribers. + update_interval=SCAN_INTERVAL, + ) + self.data: dict[Tuple[str, int], DeviceState] = {} + self.api = api + + async def _async_update_data(self) -> dict[Tuple[str, int], DeviceState]: + data: dict[Tuple[str, int], DeviceState] = {} + states = await self.api.async_get_devices_states() + for state in states: + data[state.data_key] = state + + return data diff --git a/custom_components/ha_tedee_lock/lock.py b/custom_components/ha_tedee_lock/lock.py new file mode 100644 index 0000000..d4cfc69 --- /dev/null +++ b/custom_components/ha_tedee_lock/lock.py @@ -0,0 +1,99 @@ +import asyncio +import logging +from typing import Any +from typing import cast +from typing import Optional + +from custom_components.ha_tedee_lock.model.devices.device import DeviceType +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import TedeeUpdateCoordinator +from .const import CONF_DEVICE_INFO +from .const import CONF_DEVICE_TYPE +from .const import DOMAIN +from .model.devices import device_from_dict +from .model.devices.lock import Lock +from .model.devices.lock import LockState +from .model.states.device_state_lock import DeviceStateLock + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tedee locks.""" + device_dict: dict = entry.data[CONF_DEVICE_INFO] + device_type = DeviceType(entry.data[CONF_DEVICE_TYPE]) + coordinator = hass.data[DOMAIN][entry.entry_id] + device = device_from_dict(device_dict, device_type=device_type) + if device_type == DeviceType.LOCK: + async_add_entities( + [ + TedeeLock( + lock=cast(Lock, device), + coordinator=coordinator, + config_entry=entry, + ) + ] + ) + + +class TedeeLock(CoordinatorEntity, LockEntity): + def __init__( + self, + config_entry: ConfigEntry, + lock: Lock, + coordinator: TedeeUpdateCoordinator, + ) -> None: + super().__init__(coordinator=coordinator) + self._api = coordinator.api + self._attr_unique_id = f"tedee-lock-{lock.name}-{lock.id}" + self._attr_name = lock.name + self._config_entry = config_entry + self._lock = lock + + async def async_lock(self, **kwargs: Any) -> None: + await self._api.async_operation_lock(self._lock.id) + self._lock_state().lock_properties.state = LockState.Locking + self.async_write_ha_state() + self.hass.async_create_task(self._async_delayed_state_refresh(delay_seconds=4)) + + async def async_unlock(self, **kwargs: Any) -> None: + await self._api.async_operation_unlock(self._lock.id) + self._lock_state().lock_properties.state = LockState.Unlocking + self.async_write_ha_state() + self.hass.async_create_task(self._async_delayed_state_refresh(delay_seconds=4)) + + @property + def is_locked(self) -> Optional[bool]: + return self._lock_state().lock_properties.state == LockState.Locked + + @property + def is_locking(self) -> Optional[bool]: + return self._lock_state().lock_properties.state == LockState.Locking + + @property + def is_unlocking(self) -> Optional[bool]: + return self._lock_state().lock_properties.state == LockState.Unlocking + + @property + def is_jammed(self) -> Optional[bool]: + return self._lock_state().lock_properties.state in [ + LockState.Calibrating, + LockState.Unknown, + LockState.Uncalibrated, + ] + + def _lock_state(self) -> Optional[DeviceStateLock]: + return self.coordinator.data[self._lock.data_key] + + async def _async_delayed_state_refresh(self, delay_seconds: float = 0.0): + await asyncio.sleep(delay=delay_seconds) + await self.coordinator.async_refresh() diff --git a/custom_components/ha_tedee_lock/manifest.json b/custom_components/ha_tedee_lock/manifest.json new file mode 100644 index 0000000..3a5902f --- /dev/null +++ b/custom_components/ha_tedee_lock/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "ha_tedee_lock", + "name": "Tedee Lock", + "documentation": "https://github.com/andrzejchm/ha_tedee_lock", + "issue_tracker": "https://github.com/andrzejchm/ha_tedee_lock/issues", + "dependencies": [], + "version": "1.0.0", + "config_flow": true, + "iot_class": "cloud_polling", + "codeowners": ["@andrzejchm"], + "requirements": [] +} diff --git a/custom_components/ha_tedee_lock/model/__init__.py b/custom_components/ha_tedee_lock/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/ha_tedee_lock/model/device_type.py b/custom_components/ha_tedee_lock/model/device_type.py new file mode 100644 index 0000000..195ace9 --- /dev/null +++ b/custom_components/ha_tedee_lock/model/device_type.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class DeviceType(Enum): + LOCK = "Lock" + BRIDGE = "Bridge" + KEYPAD = "Keypad" diff --git a/custom_components/ha_tedee_lock/model/devices/__init__.py b/custom_components/ha_tedee_lock/model/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/ha_tedee_lock/model/devices/bridge.py b/custom_components/ha_tedee_lock/model/devices/bridge.py new file mode 100644 index 0000000..204a1fd --- /dev/null +++ b/custom_components/ha_tedee_lock/model/devices/bridge.py @@ -0,0 +1,97 @@ +from dataclasses import dataclass +from typing import Any +from typing import Optional +from uuid import UUID + +from ..device_type import DeviceType +from ..model_utils import from_bool +from ..model_utils import from_int +from ..model_utils import from_none +from ..model_utils import from_str +from ..model_utils import from_union +from .device import Device + + +@dataclass +class Bridge(Device): + @property + def device_type(self) -> DeviceType: + return DeviceType.BRIDGE + + @property + def list_name(self) -> str: + return f'Bridge: "{self.name}" (SN: {self.serial_number})' + + organization_id: None + share_details: None + was_configured: Optional[bool] = None + beacon_major: Optional[int] = None + beacon_minor: Optional[int] = None + is_updating: Optional[bool] = None + id: Optional[int] = None + serial_number: Optional[str] = None + mac_address: Optional[str] = None + name: Optional[str] = None + user_identity: Optional[UUID] = None + type: Optional[int] = None + created: Optional[str] = None + revision: Optional[int] = None + device_revision: Optional[int] = None + target_device_revision: Optional[int] = None + time_zone: Optional[str] = None + is_connected: Optional[bool] = None + access_level: Optional[int] = None + + @staticmethod + def from_dict(obj: Any) -> 'Bridge': + assert isinstance(obj, dict) + organization_id = from_none(obj.get("organizationId")) + share_details = from_none(obj.get("shareDetails")) + was_configured = from_union([from_bool, from_none], obj.get("wasConfigured")) + beacon_major = from_union([from_int, from_none], obj.get("beaconMajor")) + beacon_minor = from_union([from_int, from_none], obj.get("beaconMinor")) + is_updating = from_union([from_bool, from_none], obj.get("isUpdating")) + _id = from_union([from_int, from_none], obj.get("id")) + serial_number = from_union([from_str, from_none], obj.get("serialNumber")) + mac_address = from_union([from_str, from_none], obj.get("macAddress")) + name = from_union([from_str, from_none], obj.get("name")) + user_identity = from_union([lambda x: UUID(x), from_none], obj.get("userIdentity")) + _type = from_union([from_int, from_none], obj.get("type")) + created = from_union([from_str, from_none], obj.get("created")) + revision = from_union([from_int, from_none], obj.get("revision")) + device_revision = from_union([from_int, from_none], obj.get("deviceRevision")) + target_device_revision = from_union([from_int, from_none], obj.get("targetDeviceRevision")) + time_zone = from_union([from_str, from_none], obj.get("timeZone")) + is_connected = from_union([from_bool, from_none], obj.get("isConnected")) + access_level = from_union([from_int, from_none], obj.get("accessLevel")) + return Bridge( + organization_id, share_details, was_configured, beacon_major, beacon_minor, is_updating, _id, serial_number, + mac_address, name, user_identity, _type, created, revision, device_revision, target_device_revision, + time_zone, is_connected, access_level, + ) + + def to_dict(self) -> dict: + return { + "organizationId": from_none(self.organization_id), + "shareDetails": from_none(self.share_details), + "wasConfigured": from_union([from_bool, from_none], self.was_configured), + "beaconMajor": from_union([from_int, from_none], self.beacon_major), + "beaconMinor": from_union([from_int, from_none], self.beacon_minor), + "isUpdating": from_union([from_bool, from_none], self.is_updating), + "id": from_union([from_int, from_none], self.id), + "serialNumber": from_union([from_str, from_none], self.serial_number), + "macAddress": from_union([from_str, from_none], self.mac_address), + "name": from_union([from_str, from_none], self.name), + "userIdentity": from_union([lambda x: str(x), from_none], self.user_identity), + "type": from_union([from_int, from_none], self.type), + "created": from_union([from_str, from_none], self.created), + "revision": from_union([from_int, from_none], self.revision), + "deviceRevision": from_union([from_int, from_none], self.device_revision), + "targetDeviceRevision": from_union([from_int, from_none], self.target_device_revision), + "timeZone": from_union([from_str, from_none], self.time_zone), + "isConnected": from_union([from_bool, from_none], self.is_connected), + "accessLevel": from_union([from_int, from_none], self.access_level), + } + + def to_state(self): + pass diff --git a/custom_components/ha_tedee_lock/model/devices/device.py b/custom_components/ha_tedee_lock/model/devices/device.py new file mode 100644 index 0000000..4575fe8 --- /dev/null +++ b/custom_components/ha_tedee_lock/model/devices/device.py @@ -0,0 +1,41 @@ +"""Tedee devices model""" +from abc import ABC +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any +from typing import Tuple + +from ..device_type import DeviceType + + +@dataclass +class Device(ABC): + """data class storing info regarding tedee device""" + + @property + @abstractmethod + def id(self) -> int: + pass + + @property + @abstractmethod + def device_type(self) -> DeviceType: + pass + + @property + @abstractmethod + def list_name(self) -> str: + pass + + @property + @abstractmethod + def name(self) -> str: + pass + + @property + def data_key(self) -> Tuple[str, int]: + return self.device_type.value, self.id + + @abstractmethod + def to_dict(self) -> dict[str, Any]: + pass diff --git a/custom_components/ha_tedee_lock/model/devices/keypad.py b/custom_components/ha_tedee_lock/model/devices/keypad.py new file mode 100644 index 0000000..747eaf5 --- /dev/null +++ b/custom_components/ha_tedee_lock/model/devices/keypad.py @@ -0,0 +1,124 @@ +from dataclasses import dataclass +from typing import Any +from typing import List +from typing import Optional +from uuid import UUID + +from ..device_type import DeviceType +from ..model_utils import from_bool +from ..model_utils import from_int +from ..model_utils import from_list +from ..model_utils import from_none +from ..model_utils import from_str +from ..model_utils import from_union +from ..model_utils import to_class +from .device import Device + + +@dataclass +class DeviceSettings: + battery_type: Optional[int] = None + sound_level: Optional[int] = None + backlight_level: Optional[int] = None + bell_button_enabled: Optional[bool] = None + lock_by_button_enabled: Optional[bool] = None + + @staticmethod + def from_dict(obj: Any) -> 'DeviceSettings': + assert isinstance(obj, dict) + battery_type = from_union([from_int, from_none], obj.get("batteryType")) + sound_level = from_union([from_int, from_none], obj.get("soundLevel")) + backlight_level = from_union([from_int, from_none], obj.get("backlightLevel")) + bell_button_enabled = from_union([from_bool, from_none], obj.get("bellButtonEnabled")) + lock_by_button_enabled = from_union([from_bool, from_none], obj.get("lockByButtonEnabled")) + return DeviceSettings(battery_type, sound_level, backlight_level, bell_button_enabled, lock_by_button_enabled) + + def to_dict(self) -> dict: + return { + "batteryType": from_union([from_int, from_none], self.battery_type), + "soundLevel": from_union([from_int, from_none], self.sound_level), + "backlightLevel": from_union([from_int, from_none], self.backlight_level), + "bellButtonEnabled": from_union([from_bool, from_none], self.bell_button_enabled), + "lockByButtonEnabled": from_union([from_bool, from_none], self.lock_by_button_enabled) + } + + +@dataclass +class Keypad(Device): + @property + def device_type(self) -> DeviceType: + return DeviceType.KEYPAD + + @property + def list_name(self) -> str: + return f'Keypad: "{self.name}" (SN: {self.serial_number})' + + organization_id: None + is_connected: None + share_details: None + connected_to_id: Optional[int] = None + connected_to_lock_id: Optional[int] = None + device_settings: Optional[DeviceSettings] = None + id: Optional[int] = None + serial_number: Optional[str] = None + mac_address: Optional[str] = None + name: Optional[str] = None + user_identity: Optional[UUID] = None + type: Optional[int] = None + created: Optional[str] = None + revision: Optional[int] = None + device_revision: Optional[int] = None + target_device_revision: Optional[int] = None + time_zone: Optional[str] = None + access_level: Optional[int] = None + software_versions: Optional[List[Any]] = None + + @staticmethod + def from_dict(obj: Any) -> 'Keypad': + assert isinstance(obj, dict) + organization_id = from_none(obj.get("organizationId")) + is_connected = from_none(obj.get("isConnected")) + share_details = from_none(obj.get("shareDetails")) + connected_to_id = from_union([from_int, from_none], obj.get("connectedToId")) + connected_to_lock_id = from_union([from_int, from_none], obj.get("connectedToLockId")) + device_settings = from_union([DeviceSettings.from_dict, from_none], obj.get("deviceSettings")) + _id = from_union([from_int, from_none], obj.get("id")) + serial_number = from_union([from_str, from_none], obj.get("serialNumber")) + mac_address = from_union([from_str, from_none], obj.get("macAddress")) + name = from_union([from_str, from_none], obj.get("name")) + user_identity = from_union([lambda x: UUID(x), from_none], obj.get("userIdentity")) + _type = from_union([from_int, from_none], obj.get("type")) + created = from_union([from_str, from_none], obj.get("created")) + revision = from_union([from_int, from_none], obj.get("revision")) + device_revision = from_union([from_int, from_none], obj.get("deviceRevision")) + target_device_revision = from_union([from_int, from_none], obj.get("targetDeviceRevision")) + time_zone = from_union([from_str, from_none], obj.get("timeZone")) + access_level = from_union([from_int, from_none], obj.get("accessLevel")) + software_versions = from_union([lambda x: from_list(lambda y: y, x), from_none], obj.get("softwareVersions")) + return Keypad(organization_id, is_connected, share_details, connected_to_id, connected_to_lock_id, + device_settings, _id, serial_number, mac_address, name, user_identity, _type, created, revision, + device_revision, target_device_revision, time_zone, access_level, software_versions) + + def to_dict(self) -> dict: + return { + "organizationId": from_none(self.organization_id), "isConnected": from_none(self.is_connected), + "shareDetails": from_none(self.share_details), + "connectedToId": from_union([from_int, from_none], self.connected_to_id), + "connectedToLockId": from_union([from_int, from_none], self.connected_to_lock_id), + "deviceSettings": from_union([lambda x: to_class(DeviceSettings, x), from_none], + self.device_settings), + "id": from_union([from_int, from_none], self.id), + "serialNumber": from_union([from_str, from_none], self.serial_number), + "macAddress": from_union([from_str, from_none], self.mac_address), + "name": from_union([from_str, from_none], self.name), + "userIdentity": from_union([lambda x: str(x), from_none], self.user_identity), + "type": from_union([from_int, from_none], self.type), + "created": from_union([from_str, from_none], self.created), + "revision": from_union([from_int, from_none], self.revision), + "deviceRevision": from_union([from_int, from_none], self.device_revision), + "targetDeviceRevision": from_union([from_int, from_none], self.target_device_revision), + "timeZone": from_union([from_str, from_none], self.time_zone), + "accessLevel": from_union([from_int, from_none], self.access_level), + "softwareVersions": from_union([lambda x: from_list(lambda y: y, x), from_none], + self.software_versions), + } diff --git a/custom_components/ha_tedee_lock/model/devices/lock.py b/custom_components/ha_tedee_lock/model/devices/lock.py new file mode 100644 index 0000000..95d4ce1 --- /dev/null +++ b/custom_components/ha_tedee_lock/model/devices/lock.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from ...model.model_utils import from_bool +from ...model.model_utils import from_int +from ...model.model_utils import from_none +from ...model.model_utils import from_str +from ...model.model_utils import from_union +from ...model.model_utils import to_class +from .device import Device +from .device import DeviceType + + +class LockState(Enum): + Uncalibrated = 0 + Calibrating = 1 + Unlocked = 2 + SemiLocked = 3 + Unlocking = 4 + Locking = 5 + Locked = 6 + Pulled = 7 + Pulling = 8 + Unknown = 9 + Updating = 10 + + +@dataclass +class LockProperties: + state: LockState = None + is_charging: Optional[bool] = None + battery_level: Optional[int] = None + state_change_result: Optional[int] = None + last_state_changed_date: Optional[str] = None + + @staticmethod + def from_dict(obj: dict) -> 'LockProperties': + state = LockState(from_union([from_int, from_none], obj.get("state")) or LockState.Unknown.value) + is_charging = from_union([from_bool, from_none], obj.get("isCharging")) + battery_level = from_union([from_int, from_none], obj.get("batteryLevel")) + state_change_result = from_union([from_int, from_none], obj.get("stateChangeResult")) + last_state_changed_date = from_union([from_str, from_none], obj.get("lastStateChangedDate")) + return LockProperties(state, is_charging, battery_level, state_change_result, last_state_changed_date) + + def to_dict(self) -> dict: + result: dict = { + "state": from_union([from_int, from_none], self.state.value), + "isCharging": from_union([from_bool, from_none], self.is_charging), + "batteryLevel": from_union([from_int, from_none], self.battery_level), + "stateChangeResult": from_union([from_int, from_none], self.state_change_result), + "lastStateChangedDate": from_union([from_str, from_none], self.last_state_changed_date), + } + return result + + +@dataclass +class Lock(Device): + + @property + def device_type(self) -> DeviceType: + return DeviceType.LOCK + + @property + def list_name(self) -> str: + return f'Lock: "{self.name}" (SN: {self.serial_number})' + + connected_to_id: Optional[int] = None + connected_to_keypad_id: Optional[int] = None + lock_properties: Optional[LockProperties] = None + id: Optional[int] = None + serial_number: Optional[str] = None + name: Optional[str] = None + type: Optional[int] = None + created: Optional[str] = None + revision: Optional[int] = None + device_revision: Optional[int] = None + target_device_revision: Optional[int] = None + time_zone: Optional[str] = None + is_connected: Optional[bool] = None + access_level: Optional[int] = None + + @staticmethod + def from_dict(obj: dict) -> 'Lock': + connected_to_id = from_union([from_int, from_none], obj.get("connectedToId")) + connected_to_keypad_id = from_union([from_int, from_none], obj.get("connectedToKeypadId")) + lock_properties = from_union([LockProperties.from_dict, from_none], obj.get("lockProperties")) + _id = from_union([from_int, from_none], obj.get("id")) + serial_number = from_union([from_str, from_none], obj.get("serialNumber")) + name = from_union([from_str, from_none], obj.get("name")) + _type = from_union([from_int, from_none], obj.get("type")) + created = from_union([from_str, from_none], obj.get("created")) + revision = from_union([from_int, from_none], obj.get("revision")) + device_revision = from_union([from_int, from_none], obj.get("deviceRevision")) + target_device_revision = from_union([from_int, from_none], obj.get("targetDeviceRevision")) + time_zone = from_union([from_str, from_none], obj.get("timeZone")) + is_connected = from_union([from_bool, from_none], obj.get("isConnected")) + access_level = from_union([from_int, from_none], obj.get("accessLevel")) + return Lock(connected_to_id, connected_to_keypad_id, lock_properties, _id, serial_number, name, _type, + created, revision, device_revision, target_device_revision, time_zone, is_connected, + access_level, ) + + def to_dict(self) -> dict: + result: dict = { + "connectedToId": from_union([from_int, from_none], self.connected_to_id), + "connectedToKeypadId": from_union([from_int, from_none], self.connected_to_keypad_id), + "lockProperties": from_union([lambda x: to_class(LockProperties, x), from_none], + self.lock_properties), + "id": from_union([from_int, from_none], self.id), + "serialNumber": from_union([from_str, from_none], self.serial_number), + "name": from_union([from_str, from_none], self.name), + "type": from_union([from_int, from_none], self.type), + "created": from_union([from_str, from_none], self.created), + "revision": from_union([from_int, from_none], self.revision), + "deviceRevision": from_union([from_int, from_none], self.device_revision), + "targetDeviceRevision": from_union([from_int, from_none], self.target_device_revision), + "timeZone": from_union([from_str, from_none], self.time_zone), + "isConnected": from_union([from_bool, from_none], self.is_connected), + "accessLevel": from_union([from_int, from_none], self.access_level), + } + return result diff --git a/custom_components/ha_tedee_lock/model/model_utils.py b/custom_components/ha_tedee_lock/model/model_utils.py new file mode 100644 index 0000000..93ea7e0 --- /dev/null +++ b/custom_components/ha_tedee_lock/model/model_utils.py @@ -0,0 +1,58 @@ +from typing import Any +from typing import Callable +from typing import cast +from typing import List +from typing import Type +from typing import TypeVar + +T = TypeVar("T") + + +def from_union(fs, x): + for f in fs: + try: + return f(x) + except Exception as ex: + print(ex) + pass + assert False + + +def from_bool(x: Any) -> bool: + assert isinstance(x, bool) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_float(x: Any) -> float: + assert isinstance(x, (float, int)) and not isinstance(x, bool) + return float(x) + + +def to_float(x: Any) -> float: + assert isinstance(x, float) + return x + + +def from_none(x: Any) -> Any: + assert x is None + return x + + +def to_class(cls: Type[T], x: Any) -> dict: + assert isinstance(x, cls) + return cast(Any, x).to_dict() + + +def from_list(f: Callable[[Any], T], json_list: Any) -> List[T]: + assert isinstance(json_list, list) + return [f(item) for item in json_list] diff --git a/custom_components/ha_tedee_lock/model/operation_result.py b/custom_components/ha_tedee_lock/model/operation_result.py new file mode 100644 index 0000000..e6cabf6 --- /dev/null +++ b/custom_components/ha_tedee_lock/model/operation_result.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Any +from typing import Optional + +from .model_utils import from_none +from .model_utils import from_str +from .model_utils import from_union + + +@dataclass +class OperationResult: + operation_id: Optional[str] = None + last_state_changed_date: Optional[str] = None + + @staticmethod + def from_dict(obj: dict[str, Any]) -> 'OperationResult': + assert isinstance(obj, dict) + operation_id = from_union([from_str, from_none], obj.get("operationId")) + last_state_changed_date = from_union([from_str, from_none], obj.get("lastStateChangedDate")) + return OperationResult(operation_id, last_state_changed_date) + + def to_dict(self) -> dict: + return { + "operationId": from_union([from_str, from_none], self.operation_id), + "lastStateChangedDate": from_union([from_str, from_none], self.last_state_changed_date), + } diff --git a/custom_components/ha_tedee_lock/model/responses/__init__.py b/custom_components/ha_tedee_lock/model/responses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/ha_tedee_lock/model/responses/my_bridge_response.py b/custom_components/ha_tedee_lock/model/responses/my_bridge_response.py new file mode 100644 index 0000000..81b235f --- /dev/null +++ b/custom_components/ha_tedee_lock/model/responses/my_bridge_response.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import Any +from typing import List +from typing import Optional + +from ..devices.bridge import Bridge +from ..model_utils import from_list +from .response_metadata import ResponseMetadata + + +@dataclass +class MyBridgeResponse: + result: Optional[List[Bridge]] + metadata: ResponseMetadata + + @staticmethod + def from_dict(dictionary: dict[str, Any]) -> 'MyBridgeResponse': + return MyBridgeResponse( + metadata=ResponseMetadata.from_dict(dictionary), + result=from_list(Bridge.from_dict, dictionary.get("result")), + ) diff --git a/custom_components/ha_tedee_lock/model/responses/my_keypad_response.py b/custom_components/ha_tedee_lock/model/responses/my_keypad_response.py new file mode 100644 index 0000000..3d4c3a7 --- /dev/null +++ b/custom_components/ha_tedee_lock/model/responses/my_keypad_response.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import Any +from typing import List +from typing import Optional + +from ..devices.keypad import Keypad +from ..model_utils import from_list +from .response_metadata import ResponseMetadata + + +@dataclass +class MyKeypadResponse: + result: Optional[List[Keypad]] + metadata: ResponseMetadata + + @staticmethod + def from_dict(dictionary: dict[str, Any]) -> 'MyKeypadResponse': + return MyKeypadResponse( + metadata=ResponseMetadata.from_dict(dictionary), + result=from_list(Keypad.from_dict, dictionary.get("result")), + ) diff --git a/custom_components/ha_tedee_lock/model/responses/my_lock_response.py b/custom_components/ha_tedee_lock/model/responses/my_lock_response.py new file mode 100644 index 0000000..a14a016 --- /dev/null +++ b/custom_components/ha_tedee_lock/model/responses/my_lock_response.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import Any +from typing import List +from typing import Optional + +from ..devices.lock import Lock +from ..model_utils import from_list +from .response_metadata import ResponseMetadata + + +@dataclass +class MyLockResponse: + result: Optional[List[Lock]] + metadata: ResponseMetadata + + @staticmethod + def from_dict(dictionary: dict[str, Any]) -> 'MyLockResponse': + return MyLockResponse( + metadata=ResponseMetadata.from_dict(dictionary), + result=from_list(Lock.from_dict, dictionary.get("result")), + ) diff --git a/custom_components/ha_tedee_lock/model/responses/my_lock_sync_response.py b/custom_components/ha_tedee_lock/model/responses/my_lock_sync_response.py new file mode 100644 index 0000000..2cb1222 --- /dev/null +++ b/custom_components/ha_tedee_lock/model/responses/my_lock_sync_response.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import Any +from typing import List + +from ..model_utils import from_list +from ..states.device_state_lock import DeviceStateLock +from .response_metadata import ResponseMetadata + + +@dataclass +class MyLockSyncResponse: + result: List[DeviceStateLock] + metadata: ResponseMetadata + + @staticmethod + def from_dict(obj: dict[str, Any]) -> 'MyLockSyncResponse': + assert isinstance(obj, dict) + return MyLockSyncResponse( + result=from_list(DeviceStateLock.from_dict, obj.get("result")), + metadata=ResponseMetadata.from_dict(obj), + ) diff --git a/custom_components/ha_tedee_lock/model/responses/operation_response.py b/custom_components/ha_tedee_lock/model/responses/operation_response.py new file mode 100644 index 0000000..e8d34bb --- /dev/null +++ b/custom_components/ha_tedee_lock/model/responses/operation_response.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from typing import Any +from typing import Optional + +from ..model_utils import from_bool +from ..model_utils import from_int +from ..model_utils import from_list +from ..model_utils import from_none +from ..model_utils import from_union +from ..model_utils import to_class +from ..operation_result import OperationResult +from .response_metadata import ResponseMetadata + + +@dataclass +class OperationResponse: + result: Optional[OperationResult] + metadata: ResponseMetadata + + @staticmethod + def from_dict(obj: dict[str, Any]) -> 'OperationResponse': + assert isinstance(obj, dict) + result = from_union([OperationResult.from_dict, from_none], obj.get("result")) + metadata = ResponseMetadata.from_dict(obj) + return OperationResponse( + result, + metadata, + ) + + def to_dict(self) -> dict: + return { + "result": from_union([lambda x: to_class(OperationResult, x), from_none], self.result), + "success": from_union([from_bool, from_none], self.metadata.success), + "errorMessages": from_union([lambda x: from_list(lambda y: y, x), from_none], + self.metadata.error_messages), + "statusCode": from_union([from_int, from_none], self.metadata.status_code), + } diff --git a/custom_components/ha_tedee_lock/model/responses/response_metadata.py b/custom_components/ha_tedee_lock/model/responses/response_metadata.py new file mode 100644 index 0000000..4f6ea6c --- /dev/null +++ b/custom_components/ha_tedee_lock/model/responses/response_metadata.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from typing import Any +from typing import Generic +from typing import List +from typing import Optional +from typing import TypeVar + +from ..model_utils import from_bool +from ..model_utils import from_int +from ..model_utils import from_list +from ..model_utils import from_none +from ..model_utils import from_union + +T = TypeVar("T") + + +@dataclass +class ResponseMetadata(Generic[T]): + success: Optional[bool] = None + error_messages: Optional[List[Any]] = None + status_code: Optional[int] = None + + @staticmethod + def from_dict( + obj: dict[str, Any], + ): + return ResponseMetadata( + success=from_union([from_bool, from_none], obj.get("success")), + error_messages=from_union([lambda x: from_list(lambda y: y, x), from_none], obj.get("errorMessages")), + status_code=from_union([from_int, from_none], obj.get("statusCode")), + ) + + def to_dict(self) -> dict: + return { + "success": from_union([from_bool, from_none], self.success), + "errorMessages": from_union([lambda x: from_list(lambda y: y, x), from_none], self.error_messages), + "statusCode": from_union([from_int, from_none], self.status_code), + } diff --git a/custom_components/ha_tedee_lock/model/states/__init__.py b/custom_components/ha_tedee_lock/model/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/ha_tedee_lock/model/states/device_state.py b/custom_components/ha_tedee_lock/model/states/device_state.py new file mode 100644 index 0000000..5742f3d --- /dev/null +++ b/custom_components/ha_tedee_lock/model/states/device_state.py @@ -0,0 +1,23 @@ +from abc import ABC +from abc import abstractmethod +from dataclasses import dataclass +from typing import Tuple + +from ..device_type import DeviceType + + +@dataclass +class DeviceState(ABC): + @property + @abstractmethod + def device_type(self) -> DeviceType: + pass + + @property + @abstractmethod + def id(self) -> int: + pass + + @property + def data_key(self) -> Tuple[str, int]: + return self.device_type.value, self.id diff --git a/custom_components/ha_tedee_lock/model/states/device_state_lock.py b/custom_components/ha_tedee_lock/model/states/device_state_lock.py new file mode 100644 index 0000000..7422b3d --- /dev/null +++ b/custom_components/ha_tedee_lock/model/states/device_state_lock.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from typing import Any +from typing import Optional + +from ..device_type import DeviceType +from ..devices.lock import LockProperties +from ..model_utils import from_bool +from ..model_utils import from_int +from ..model_utils import from_none +from ..model_utils import from_union +from ..model_utils import to_class +from ..states.device_state import DeviceState + + +@dataclass +class DeviceStateLock(DeviceState): + + @property + def device_type(self) -> DeviceType: + return DeviceType.LOCK + + id: Optional[int] = None + is_connected: Optional[bool] = None + lock_properties: Optional[LockProperties] = None + + @staticmethod + def from_dict(obj: dict[str, Any]) -> 'DeviceStateLock': + assert isinstance(obj, dict) + _id = from_int(obj.get("id")) + is_connected = from_union([from_bool, from_none], obj.get("isConnected")) + lock_properties = from_union([LockProperties.from_dict, from_none], obj.get("lockProperties")) + return DeviceStateLock( + _id, + is_connected, + lock_properties, + ) + + def to_dict(self) -> dict: + result: dict = { + "id": from_int(self.id), + "isConnected": from_union([from_bool, from_none], self.is_connected), + "lockProperties": from_union([lambda x: to_class(LockProperties, x), from_none], self.lock_properties), + } + return result diff --git a/custom_components/ha_tedee_lock/sensor.py b/custom_components/ha_tedee_lock/sensor.py new file mode 100644 index 0000000..c4832ed --- /dev/null +++ b/custom_components/ha_tedee_lock/sensor.py @@ -0,0 +1,61 @@ +from typing import Optional + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.components.sensor import SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_DEVICE_INFO +from .const import CONF_DEVICE_TYPE +from .const import DOMAIN +from .model.device_type import DeviceType +from .model.devices.lock import Lock +from .model.states.device_state_lock import DeviceStateLock + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + device_dict: dict = entry.data[CONF_DEVICE_INFO] + device_type = DeviceType(entry.data[CONF_DEVICE_TYPE]) + coordinator = hass.data[DOMAIN][entry.entry_id] + if device_type == DeviceType.LOCK: + async_add_entities([ + LockBatteryLevelSensor( + lock=Lock.from_dict(device_dict), + coordinator=coordinator, + ), + ]) + pass + + +class LockBatteryLevelSensor(CoordinatorEntity, SensorEntity): + + def __init__( + self, + lock: Lock, + coordinator: DataUpdateCoordinator, + ): + super().__init__(coordinator) + self._lock = lock + self.entity_description = SensorEntityDescription( + key="lock_battery_level", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + ) + self._attr_unique_id = f"tedee-lock-{lock.name}-{lock.id}-lock-battery-level" + self._attr_name = f"{lock.name} lock battery level" + + @property + def native_value(self) -> int: + return self._lock_state().lock_properties.battery_level + + def _lock_state(self) -> Optional[DeviceStateLock]: + return self.coordinator.data[self._lock.data_key] diff --git a/custom_components/ha_tedee_lock/translations/en.json b/custom_components/ha_tedee_lock/translations/en.json new file mode 100644 index 0000000..ed5e7c8 --- /dev/null +++ b/custom_components/ha_tedee_lock/translations/en.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "description": "Instruction on how to obtain your personal access token (PAK) can be found here: https://tedee-tedee-api-doc.readthedocs-hosted.com/en/latest/howtos/authenticate.html#personal-access-key", + "data": { + "personal_access_token": "Personal Access Token" + } + }, + "select_devices": { + "description": "Select devices you'd like to use in Home Assistant", + "data": { + "select_devices": "Devices" + } + } + }, + "error": { + "no_devices_found": "Account you provided has no devices set up", + "unknown_error": "Something weird happened, please check logs" + }, + "abort": { + "single_instance_allowed": "Only a single instance is allowed.", + "already_configured": "Device is already configured" + } + }, + "options": { + "step": { + "user": { + "data": { + "personal_access_token": "Personal Access Token" + } + } + } + } +} diff --git a/custom_components/ha_tedee_lock/translations/pl.json b/custom_components/ha_tedee_lock/translations/pl.json new file mode 100644 index 0000000..4512b44 --- /dev/null +++ b/custom_components/ha_tedee_lock/translations/pl.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "description": "Instrukcja jak uzyskać personal access token (PAK) znajduje się tutaj: https://tedee-tedee-api-doc.readthedocs-hosted.com/en/latest/howtos/authenticate.html#personal-access-key", + "data": { + "personal_access_token": "Personal Access Token" + } + }, + "select_devices": { + "description": "Wybierz urządzenia, które chcesz użyć w Home Assistant", + "data": { + "select_devices": "Urządzenia" + } + } + }, + "error": { + "no_devices_found": "Na podanym koncie nie ma żadnych urządzeń", + "unknown_error": "Stalo sie cos dziwnego, sprawdź logi" + }, + "abort": { + "single_instance_allowed": "Tylko jedno urządzenie o tych samych parametrach może być dodane.", + "already_configured": "Urządzenie jest już skonfigurowane" + } + }, + "options": { + "step": { + "user": { + "data": { + "personal_access_token": "Personal Access Token" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..bdb0001 --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "Tedee Lock", + "hacs": "1.6.0", + "homeassistant": "2022.6.0", + "render_readme": true +} diff --git a/info.md b/info.md new file mode 100644 index 0000000..d3f12fe --- /dev/null +++ b/info.md @@ -0,0 +1,59 @@ +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]][license] + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +[![Discord][discord-shield]][discord] +[![Community Forum][forum-shield]][forum] + +**This component will set up the following platforms.** + +| Platform | Description | +| --------------- | ----------------------------------- | +| `binary_sensor` | Show something `True` or `False`. | +| `sensor` | Show info from API. | +| `switch` | Switch something `True` or `False`. | + +![example][exampleimg] + +{% if not installed %} + +## Installation + +1. Click install. +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Tedee Lock". + +{% endif %} + +## Configuration is done in the UI + + + +## Credits + +This project was generated from [@oncleben31](https://github.com/oncleben31)'s [Home Assistant Custom Component Cookiecutter](https://github.com/oncleben31/cookiecutter-homeassistant-custom-component) template. + +Code template was mainly taken from [@Ludeeus](https://github.com/ludeeus)'s [integration_blueprint][integration_blueprint] template + +--- + +[integration_blueprint]: https://github.com/custom-components/integration_blueprint +[buymecoffee]: https://www.buymeacoffee.com/ludeeus +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge +[commits-shield]: https://img.shields.io/github/commit-activity/y/andrzejchm/ha_tedee_lock.svg?style=for-the-badge +[commits]: https://github.com/andrzejchm/ha_tedee_lock/commits/main +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[discord]: https://discord.gg/Qa5fW2R +[discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge +[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge +[forum]: https://community.home-assistant.io/ +[license]: https://github.com/andrzejchm/ha_tedee_lock/blob/main/LICENSE +[license-shield]: https://img.shields.io/github/license/andrzejchm/ha_tedee_lock.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-%40andrzejchm-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/andrzejchm/ha_tedee_lock.svg?style=for-the-badge +[releases]: https://github.com/andrzejchm/ha_tedee_lock/releases +[user_profile]: https://github.com/andrzejchm diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..56271ef --- /dev/null +++ b/pylintrc @@ -0,0 +1,3 @@ +[MESSAGES CONTROL] +disable=too-few-public-methods +disable=too-many-instance-attributes diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..f587561 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1 @@ +homeassistant==2022.6.6 diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..5d063eb --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,3 @@ +-r requirements_dev.txt +-r requirements.txt +pytest-homeassistant-custom-component==0.9.16 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2795e7e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,46 @@ +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# by default isort don't check module indexes +not_skip = __init__.py +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = custom_components.ha_tedee_lock, tests +combine_as_imports = true + +[tool:pytest] +addopts = -qq --cov=custom_components.ha_tedee_lock +console_output_style = count + +[coverage:run] +branch = False + +[coverage:report] +show_missing = true +fail_under = 100 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..7593980 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Tedee Lock integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8de7f4c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +"""Global fixtures for Tedee Lock integration.""" +from unittest.mock import patch + +import pytest + +pytest_plugins = "pytest_homeassistant_custom_component" + + +# This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent +# notifications. These calls would fail without this fixture since the persistent_notification +# integration is never loaded during a test. +@pytest.fixture(name="skip_notifications", autouse=True) +def skip_notifications_fixture(): + """Skip notification calls.""" + with patch("homeassistant.components.persistent_notification.async_create"), patch( + "homeassistant.components.persistent_notification.async_dismiss" + ): + yield + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """auto enables custom integrations""" + yield + + +# This fixture, when used, will result in calls to async_get_devices_states to return None. To have the call +# return a value, we would add the `return_value=` parameter to the patch call. +@pytest.fixture(name="bypass_get_data") +def bypass_get_data_fixture(): + """Skip calls to get data from API.""" + with patch("custom_components.ha_tedee_lock.TedeeLockApiClient.async_get_devices_states"): + yield + + +# In this fixture, we are forcing calls to async_get_devices_states to raise an Exception. This is useful +# for exception handling. +@pytest.fixture(name="error_on_get_data") +def error_get_data_fixture(): + """Simulate error when retrieving data from API.""" + with patch( + "custom_components.ha_tedee_lock.TedeeLockApiClient.async_get_devices_states", + side_effect=Exception, + ): + yield diff --git a/tests/const.py b/tests/const.py new file mode 100644 index 0000000..7322477 --- /dev/null +++ b/tests/const.py @@ -0,0 +1,94 @@ +"""Constants for Tedee Lock tests.""" +from custom_components.ha_tedee_lock import CONF_DEVICE_TYPE +from custom_components.ha_tedee_lock import CONF_PERSONAL_ACCESS_TOKEN +from custom_components.ha_tedee_lock import DeviceType +from custom_components.ha_tedee_lock.const import CONF_DEVICE_ID +from custom_components.ha_tedee_lock.const import CONF_DEVICE_INFO +from custom_components.ha_tedee_lock.model.states.device_state_lock import DeviceStateLock + +MOCK_DEVICE_INFO = { + "userSettings": { + "autoUnlockEnabled": True, + "autoUnlockConfirmEnabled": False, + "autoUnlockRangeIn": 100, + "autoUnlockRangeOut": 200, + "autoUnlockTimeout": 20, + "autoUnlockCheckWiFi": True, + "wiFiName": None, + "location": { + "latitude": 21.0134591822131117, + "longitude": 38.997140404374477 + }, + "bellAlertDisabled": None + }, + "connectedToId": 12830, + "connectedToKeypadId": 11344, + "deviceSettings": { + "autoLockEnabled": False, + "autoLockDelay": 15, + "autoLockImplicitEnabled": False, + "autoLockImplicitDelay": 5, + "pullSpringEnabled": False, + "pullSpringDuration": 2, + "autoPullSpringEnabled": True, + "postponedLockEnabled": True, + "postponedLockDelay": 5, + "buttonLockEnabled": True, + "buttonUnlockEnabled": True, + "hasUnpairedKeypad": False + }, + "lockProperties": { + "state": 6, + "isCharging": False, + "batteryLevel": 39, + "stateChangeResult": None, + "lastStateChangedDate": "2022-06-28T17:18:38.962" + }, + "beaconMajor": 33221, + "beaconMinor": 4582, + "id": 12248, + "organizationId": None, + "serialNumber": "22121392-004852", + "macAddress": "00:00:00:00:00:00", + "name": "main door", + "userIdentity": "0b96bdc3-3f0d-4c67-b40a-a63f1c2d9a5e", + "type": 2, + "created": "2022-03-23T15:35:26.0400127", + "revision": 18, + "deviceRevision": 13, + "targetDeviceRevision": 13, + "timeZone": "Europe/Amsterdam", + "isConnected": True, + "accessLevel": 2, + "shareDetails": None, + "softwareVersions": [ + { + "softwareType": 0, + "version": "1.4.60536", + "updateAvailable": False + } + ] +} + +LOCK_STATE_DICT = { + "id": MOCK_DEVICE_INFO["id"], + "isConnected": True, + "lockProperties": { + "state": 6, + "isCharging": False, + "batteryLevel": 45, + "stateChangeResult": None, + "lastStateChangedDate": "2022-06-23T15:56:31.44" + } +} + +MOCK_CONFIG = { + CONF_PERSONAL_ACCESS_TOKEN: "sampleAccessTokenGoesHere", + CONF_DEVICE_TYPE: DeviceType.LOCK.value, + CONF_DEVICE_ID: LOCK_STATE_DICT["id"], + CONF_DEVICE_INFO: MOCK_DEVICE_INFO, +} + +MOCK_STATES = [ + DeviceStateLock.from_dict(LOCK_STATE_DICT), +] diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..8e7293f --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,67 @@ +"""Tests for Tedee Lock api.""" +import pytest +from _pytest.logging import LogCaptureFixture +from custom_components.ha_tedee_lock import DeviceType +from custom_components.ha_tedee_lock import TedeeLockApiClient +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker +from tests.const import LOCK_STATE_DICT +from tests.const import MOCK_DEVICE_INFO + + +async def test_api(hass, aioclient_mock: AiohttpClientMocker, caplog: LogCaptureFixture): + """Test API calls.""" + + # To test the api submodule, we first create an instance of our API client + api = TedeeLockApiClient( + "accessToken", + async_get_clientsession(hass), + hass, + ) + + # Use aioclient_mock which is provided by `pytest_homeassistant_custom_components` + # to mock responses to aiohttp requests. In this case we are telling the mock to + # return {"test": "test"} when a `GET` call is made to the specified URL. We then + # call `async_get_data` which will make that `GET` request. + aioclient_mock.get( + "https://api.tedee.com/api/v1.25/my/lock", + json={ + "result": [MOCK_DEVICE_INFO], + "success": True, + "errorMessages": [], + "statusCode": 200 + }, + ) + result = await api.async_get_devices_info() + assert result[0].id == MOCK_DEVICE_INFO["id"] + assert result[0].device_type == DeviceType.LOCK + assert result[0].name == "main door" + aioclient_mock.clear_requests() + + aioclient_mock.get("https://api.tedee.com/api/v1.25/my/lock/sync", + json={ + "result": [LOCK_STATE_DICT], + "success": True, + "errorMessages": [], + "statusCode": 200 + }, + ) + + result = await api.async_get_devices_states() + assert result[0].id == MOCK_DEVICE_INFO["id"] + assert result[0].device_type == DeviceType.LOCK + + aioclient_mock.clear_requests() + + aioclient_mock.get( + "https://api.tedee.com/api/v1.25/my/lock/sync", exc=TimeoutError + ) + with pytest.raises(TimeoutError): + await api.async_get_devices_states() + + aioclient_mock.clear_requests() + aioclient_mock.get( + "https://api.tedee.com/api/v1.25/my/lock/sync", json={"something": "else"} + ) + with pytest.raises(AssertionError): + await api.async_get_locks_states() diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..a297b80 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,102 @@ +"""Test Tedee Lock config flow.""" +from unittest.mock import patch + +import pytest +from custom_components.ha_tedee_lock import DeviceType +from custom_components.ha_tedee_lock.const import CONF_DEVICE_INFO +from custom_components.ha_tedee_lock.const import CONF_DEVICE_TYPE +from custom_components.ha_tedee_lock.const import CONF_PERSONAL_ACCESS_TOKEN +from custom_components.ha_tedee_lock.const import DOMAIN +from homeassistant import config_entries +from homeassistant import data_entry_flow +from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker +from tests.const import MOCK_CONFIG +from tests.const import MOCK_DEVICE_INFO + + +# This fixture bypasses the actual setup of the integration +# since we only want to test the config flow. We test the +# actual functionality of the integration in other test modules. + + +@pytest.fixture(autouse=True) +def bypass_setup_fixture(): + """Prevent setup.""" + with patch( + "custom_components.ha_tedee_lock.async_setup_entry", + return_value=True, + ): + yield + + +# Here we simiulate a successful config flow from the backend. +# Note that we use the `bypass_get_data` fixture here because +# we want the config flow validation to succeed during the test. +async def test_successful_config_flow(hass, aioclient_mock: AiohttpClientMocker): + """Test a successful config flow.""" + await _mock_device_info(aioclient_mock) + + # Initialize a config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Check that the config flow shows the user form as the first step + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # If a user were to enter `test_username` for username and `test_password` + # for password, it would result in this function call + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PERSONAL_ACCESS_TOKEN: MOCK_CONFIG[CONF_PERSONAL_ACCESS_TOKEN]} + ) + + # Check that the config flow is complete and a new entry is created with + # the input data + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "select_device" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"selected_device": 'Lock: "main door" (SN: 22121392-004852)'} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "main door" + assert result["data"][CONF_PERSONAL_ACCESS_TOKEN] == MOCK_CONFIG[CONF_PERSONAL_ACCESS_TOKEN] + assert result["data"][CONF_DEVICE_TYPE] == DeviceType.LOCK.value + assert result["data"][CONF_DEVICE_INFO]["id"] == MOCK_DEVICE_INFO["id"] + assert result["result"] + + +# In this case, we want to simulate a failure during the config flow. +# We use the `error_on_get_data` mock instead of `bypass_get_data` +# (note the function parameters) to raise an Exception during +# validation of the input config. +async def test_failed_config_flow(hass, error_on_get_data): + """Test a failed config flow due to credential validation failure.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PERSONAL_ACCESS_TOKEN: MOCK_CONFIG[CONF_PERSONAL_ACCESS_TOKEN], } + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown_error"} + + +async def _mock_device_info(aioclient_mock: AiohttpClientMocker): + aioclient_mock.get( + "https://api.tedee.com/api/v1.25/my/lock", + json={ + "result": [MOCK_DEVICE_INFO], + "success": True, + "errorMessages": [], + "statusCode": 200 + }, + ) diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..31aef3f --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,55 @@ +"""Test Tedee Lock setup process.""" +from unittest.mock import patch + +import pytest +from custom_components.ha_tedee_lock import async_reload_entry +from custom_components.ha_tedee_lock import async_setup_entry +from custom_components.ha_tedee_lock import async_unload_entry +from custom_components.ha_tedee_lock import TedeeUpdateCoordinator +from custom_components.ha_tedee_lock.const import DOMAIN +from homeassistant.exceptions import ConfigEntryNotReady +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from .const import MOCK_CONFIG +from .const import MOCK_STATES + + +# We can pass fixtures as defined in conftest.py to tell pytest to use the fixture +# for a given test. We can also leverage fixtures and mocks that are available in +# Home Assistant using the pytest_homeassistant_custom_component plugin. +# Assertions allow you to verify that the return value of whatever is on the left +# side of the assertion matches with the right side. +async def test_setup_unload_and_reload_entry(hass): + """Test entry setup and unload.""" + with patch( + "custom_components.ha_tedee_lock.TedeeLockApiClient.async_get_devices_states", + return_value=MOCK_STATES, + ): + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + + # Set up the entry and assert that the values set during setup are where we expect + # them to be. + assert await async_setup_entry(hass, config_entry) + assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + assert (isinstance(hass.data[DOMAIN][config_entry.entry_id], TedeeUpdateCoordinator)) + + # Reload the entry and assert that the data from above is still there + assert await async_reload_entry(hass, config_entry) is None + assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + assert (isinstance(hass.data[DOMAIN][config_entry.entry_id], TedeeUpdateCoordinator)) + + # Unload the entry and verify that the data has been removed + assert await async_unload_entry(hass, config_entry) + assert config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_setup_entry_exception(hass, error_on_get_data): + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + + # In this case we are testing the condition where async_setup_entry raises + # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates + # an error. + with pytest.raises(ConfigEntryNotReady): + assert await async_setup_entry(hass, config_entry)