diff --git a/.cspell.json b/.cspell.json
new file mode 100644
index 0000000..b798d29
--- /dev/null
+++ b/.cspell.json
@@ -0,0 +1,16 @@
+{
+ "ignorePaths": [
+ "**/node_modules/**",
+ "**/vscode-extension/**",
+ "**/.git/**",
+ "**/.pnpm-lock.json",
+ ".vscode",
+ "megalinter",
+ "package-lock.json",
+ "report"
+ ],
+ "language": "en",
+ "noConfigSearch": true,
+ "words": ["megalinter", "oxsecurity"],
+ "version": "0.2"
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..15a0eb1
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+plejd/tests/*
+__pycache__
+.vscode
+build
+dist
+*.egg-info
+.venv
+megalinter-reports/
diff --git a/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml
new file mode 100644
index 0000000..2cb2609
--- /dev/null
+++ b/.github/workflows/mega-linter.yml
@@ -0,0 +1,184 @@
+# MegaLinter GitHub Action configuration file
+# More info at https://megalinter.io
+---
+name: MegaLinter
+
+# Trigger mega-linter at every push. Action will also be visible from
+# Pull Requests to main
+on:
+ # Comment this line to trigger action only on pull-requests
+ # (not recommended if you don't pay for GH Actions)
+ push:
+
+ pull_request:
+ branches:
+ - main
+ - master
+
+# Comment env block if you do not want to apply fixes
+env:
+ # Apply linter fixes configuration
+ #
+ # When active, APPLY_FIXES must also be defined as environment variable
+ # (in github/workflows/mega-linter.yml or other CI tool)
+ APPLY_FIXES: all
+
+ # Decide which event triggers application of fixes in a commit or a PR
+ # (pull_request, push, all)
+ APPLY_FIXES_EVENT: pull_request
+
+ # If APPLY_FIXES is used, defines if the fixes are directly committed (commit)
+ # or posted in a PR (pull_request)
+ APPLY_FIXES_MODE: commit
+
+concurrency:
+ group: ${{ github.ref }}-${{ github.workflow }}
+ cancel-in-progress: true
+
+jobs:
+ megalinter:
+ name: MegaLinter
+ runs-on: ubuntu-latest
+
+ # Give the default GITHUB_TOKEN write permission to commit and push, comment
+ # issues, and post new Pull Requests; remove the ones you do not need
+ permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+
+ steps:
+ # Git Checkout
+ - name: Checkout Code
+ uses: actions/checkout@v3
+ with:
+ token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }}
+
+ # If you use VALIDATE_ALL_CODEBASE = true, you can remove this line to
+ # improve performance
+ fetch-depth: 0
+
+ # MegaLinter
+ - name: MegaLinter
+
+ # You can override MegaLinter flavor used to have faster performances
+ # More info at https://megalinter.io/latest/flavors/
+ uses: oxsecurity/megalinter/flavors/python@v7
+
+ id: ml
+
+ # All available variables are described in documentation
+ # https://megalinter.io/latest/config-file/
+ env:
+ # Validates all source when push on main, else just the git diff with
+ # main. Override with true if you always want to lint all sources
+ #
+ # To validate the entire codebase, set to:
+ # VALIDATE_ALL_CODEBASE: true
+ #
+ # To validate only diff with main, set to:
+ # VALIDATE_ALL_CODEBASE: >-
+ # ${{
+ # github.event_name == 'push' &&
+ # github.ref == 'refs/heads/main'
+ # }}
+ VALIDATE_ALL_CODEBASE: true
+
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # ADD YOUR CUSTOM ENV VARIABLES HERE TO OVERRIDE VALUES OF
+ # .mega-linter.yml AT THE ROOT OF YOUR REPOSITORY
+
+ # Upload MegaLinter artifacts
+ - name: Archive production artifacts
+ uses: actions/upload-artifact@v3
+ if: success() || failure()
+ with:
+ name: MegaLinter reports
+ path: |
+ megalinter-reports
+ mega-linter.log
+
+ # Create pull request if applicable
+ # (for now works only on PR from same repository, not from forks)
+ - name: Create Pull Request with applied fixes
+ uses: peter-evans/create-pull-request@v5
+ id: cpr
+ if: >-
+ steps.ml.outputs.has_updated_sources == 1 &&
+ (
+ env.APPLY_FIXES_EVENT == 'all' ||
+ env.APPLY_FIXES_EVENT == github.event_name
+ ) &&
+ env.APPLY_FIXES_MODE == 'pull_request' &&
+ (
+ github.event_name == 'push' ||
+ github.event.pull_request.head.repo.full_name == github.repository
+ ) &&
+ !contains(github.event.head_commit.message, 'skip fix')
+ with:
+ token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }}
+ commit-message: "[MegaLinter] Apply linters automatic fixes"
+ title: "[MegaLinter] Apply linters automatic fixes"
+ labels: bot
+
+ - name: Create PR output
+ if: >-
+ steps.ml.outputs.has_updated_sources == 1 &&
+ (
+ env.APPLY_FIXES_EVENT == 'all' ||
+ env.APPLY_FIXES_EVENT == github.event_name
+ ) &&
+ env.APPLY_FIXES_MODE == 'pull_request' &&
+ (
+ github.event_name == 'push' ||
+ github.event.pull_request.head.repo.full_name == github.repository
+ ) &&
+ !contains(github.event.head_commit.message, 'skip fix')
+ run: |
+ echo "PR Number - ${{ steps.cpr.outputs.pull-request-number }}"
+ echo "PR URL - ${{ steps.cpr.outputs.pull-request-url }}"
+
+ # Push new commit if applicable
+ # (for now works only on PR from same repository, not from forks)
+ - name: Prepare commit
+ if: >-
+ steps.ml.outputs.has_updated_sources == 1 &&
+ (
+ env.APPLY_FIXES_EVENT == 'all' ||
+ env.APPLY_FIXES_EVENT == github.event_name
+ ) &&
+ env.APPLY_FIXES_MODE == 'commit' &&
+ github.ref != 'refs/heads/main' &&
+ (
+ github.event_name == 'push' ||
+ github.event.pull_request.head.repo.full_name == github.repository
+ ) &&
+ !contains(github.event.head_commit.message, 'skip fix')
+ run: sudo chown -Rc $UID .git/
+
+ - name: Commit and push applied linter fixes
+ uses: stefanzweifel/git-auto-commit-action@v4
+ if: >-
+ steps.ml.outputs.has_updated_sources == 1 &&
+ (
+ env.APPLY_FIXES_EVENT == 'all' ||
+ env.APPLY_FIXES_EVENT == github.event_name
+ ) &&
+ env.APPLY_FIXES_MODE == 'commit' &&
+ github.ref != 'refs/heads/main' &&
+ (
+ github.event_name == 'push' ||
+ github.event.pull_request.head.repo.full_name == github.repository
+ ) &&
+ !contains(github.event.head_commit.message, 'skip fix')
+ with:
+ branch: >-
+ ${{
+ github.event.pull_request.head.ref ||
+ github.head_ref ||
+ github.ref
+ }}
+ commit_message: "[MegaLinter] Apply linters fixes"
+ commit_user_name: megalinter-bot
+ commit_user_email: nicolas.vuillamy@ox.security
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
new file mode 100644
index 0000000..bf6aa2d
--- /dev/null
+++ b/.github/workflows/python-tests.yml
@@ -0,0 +1,33 @@
+---
+# This workflow will install Python dependencies and run tests with a single version of Python
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
+
+name: Python tests
+
+on:
+ push:
+ # Run against all branches
+ # branches: [ "main" ]
+ pull_request:
+ branches: ["main"]
+
+permissions:
+ contents: read
+
+jobs:
+ # Inspired by https://github.com/actions/setup-python/blob/main/docs/advanced-usage.md#caching-packages
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install poetry
+ run: pipx install poetry
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v4
+ with:
+ python-version: "3.11"
+ cache: "poetry"
+ - name: Install dependencies
+ run: poetry install
+ - name: Run tests
+ run: poetry run pytest
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1a105c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+settings.yaml
+*.pkl
+__pycache__
+.vscode
+build
+dist
+*.egg-info
+.venv
+megalinter-reports/
diff --git a/.gitleaks.toml b/.gitleaks.toml
new file mode 100644
index 0000000..2bb1f85
--- /dev/null
+++ b/.gitleaks.toml
@@ -0,0 +1,6 @@
+[allowlist]
+# Plejd API key is not a secret, but detected so by gitleaks
+description = "Plejd API key"
+regexes = [
+'''zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak'''
+]
diff --git a/.hook-scripts/clean-up-pyc-and-pyo-files b/.hook-scripts/clean-up-pyc-and-pyo-files
new file mode 100755
index 0000000..8440194
--- /dev/null
+++ b/.hook-scripts/clean-up-pyc-and-pyo-files
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+
+find . -iname '*.pyc' -delete
+find . -iname '*.pyo' -delete
diff --git a/.jscpd.json b/.jscpd.json
new file mode 100644
index 0000000..2cee5f5
--- /dev/null
+++ b/.jscpd.json
@@ -0,0 +1,15 @@
+{
+ "threshold": 0,
+ "reporters": ["html", "markdown"],
+ "ignore": [
+ "**/node_modules/**",
+ "**/.git/**",
+ "**/.rbenv/**",
+ "**/.venv/**",
+ "**/*cache*/**",
+ "**/.github/**",
+ "**/.idea/**",
+ "**/report/**",
+ "**/*.svg"
+ ]
+}
diff --git a/.mega-linter.yml b/.mega-linter.yml
new file mode 100644
index 0000000..b3428b6
--- /dev/null
+++ b/.mega-linter.yml
@@ -0,0 +1,35 @@
+# Configuration file for MegaLinter
+#
+# See all available variables at https://megalinter.io/latest/config-file/ and in
+# linters documentation
+
+# all, none, or list of linter keys
+APPLY_FIXES: all
+
+PYTHON_PYLINT_PRE_COMMANDS:
+ # Help pylint understand pydantic models
+ - command: pip install pylint-pydantic
+ venv: pylint
+ continue_if_failed: false
+
+PYTHON_PYLINT_ARGUMENTS:
+ # Disable import checks because it needs all the Python dependencies installed in the linter
+ # https://github.com/oxsecurity/megalinter/issues/2030
+ - "--disable=E0401,E0611"
+ # Help pylint understand pydantic models
+ - "--load-plugins=pylint_pydantic"
+PYTHON_FLAKE8_ARGUMENTS:
+ - "--max-line-length=100"
+
+DISABLE_LINTERS:
+ - SPELL_CSPELL
+ - PYTHON_PYRIGHT # TODO this should not be disabled!!
+ - PYTHON_MYPY # TODO this should not be disabled!!
+ - REPOSITORY_CHECKOV # TODO this should not be disabled!!
+ - COPYPASTE_JSCPD # TODO this should not be disabled!!
+
+SHOW_ELAPSED_TIME: true
+
+FILEIO_REPORTER: false
+
+PYTHON_BANDIT_FILTER_REGEX_EXCLUDE: test_
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..9625e10
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,46 @@
+---
+# See https://pre-commit.com for more information
+# See https://pre-commit.com/hooks.html for more hooks
+repos:
+ - repo: local
+ hooks:
+ - id: clean-up-pyc-and-pyo-files
+ name: Scrub all .pyc and .pyo files before committing
+ entry: ./.hook-scripts/clean-up-pyc-and-pyo-files
+ language: script
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.4.0
+ hooks:
+ - id: check-added-large-files
+ - id: check-docstring-first
+ - id: check-executables-have-shebangs
+ - id: check-merge-conflict
+ - id: check-shebang-scripts-are-executable
+ - id: check-symlinks
+ - id: debug-statements
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+
+ - repo: https://github.com/psf/black
+ rev: "23.1.0"
+ hooks:
+ - id: black
+ args: [--line-length=100]
+
+ - repo: https://github.com/pycqa/flake8
+ rev: "6.0.0"
+ hooks:
+ - id: flake8
+ args: [--max-line-length=100]
+
+ - repo: https://github.com/thlorenz/doctoc
+ rev: v2.2.0
+ hooks:
+ - id: doctoc
+
+ - repo: https://github.com/python-poetry/poetry
+ rev: "1.4.1"
+ hooks:
+ - id: poetry-check
+ - id: poetry-lock
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f37e938
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,92 @@
+# Contributing to Plejd plejd-mqtt-ha
+
+Thanks for considering contributing to the project!
+
+
+
+**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Getting Started](#getting-started)
+ - [Setting up the environment](#setting-up-the-environment)
+- [Coding Standards](#coding-standards)
+- [Pull Requests](#pull-requests)
+ - [Code linting](#code-linting)
+- [Pre-commit Hooks](#pre-commit-hooks)
+
+
+
+## Getting Started
+
+Before you start contributing, please review these guidelines:
+
+1. Check out the project's issue tracker and pull requests to see if someone else has already reported and/or fixed the issue you're facing.
+
+2. If not, open a new issue. Please provide as much information as possible to help the maintainers understand and solve the problem.
+
+3. If you think you can fix or implement it yourself, fork the project and submit a pull request. Please make sure to follow the coding standards and test your changes.
+
+### Setting up the environment
+
+This project uses [Poetry](https://python-poetry.org/) for dependency management. If you don't have Poetry installed, you can install it with:
+
+```bash
+curl -sSL https://install.python-poetry.org | python -
+```
+
+Once Poetry is installed, you can set up your environment with:
+
+```bash
+poetry install
+```
+This will create a virtual environment and install all the necessary dependencies so you can start contributing to the project.
+To spawn a shell within the environment:
+
+```bash
+poetry shell
+```
+
+## Coding Standards
+
+Please ensure your code adheres to the following standards:
+
+- Follow the style used in the existing codebase.
+- Include comments in your code where necessary.
+- Write tests for your changes.
+
+## Pull Requests
+
+When submitting a pull request:
+
+- Include a description of what your change intends to do.
+- Be sure to link to the issue that your change is related to, if applicable.
+- Make sure your pull request includes tests.
+
+### Code linting
+This project uses [Mega-Linter](https://nvuillam.github.io/mega-linter/), an open-source linter that analyzes consistency and quality of your code.
+
+Before submitting a pull request, please ensure your changes do not introduce any new linting errors. You can run Mega-Linter locally to check your code before committing:
+
+```bash
+npx mega-linter-runner --flavor python
+```
+This command will run Mega-Linter against your local codebase and report any issues it finds.
+
+## Pre-commit Hooks
+
+This project uses [pre-commit](https://pre-commit.com/) to ensure that code committed to the repository meets certain standards and passes linting tests. Before you can commit changes to the repository, your changes will be automatically checked by pre-commit hooks.
+
+To install the pre-commit hooks, you need to have pre-commit installed on your local machine. You can install it using pip:
+
+```bash
+pip install pre-commit
+```
+
+Once pre-commit is installed, you can install the pre-commit hooks with:
+
+```bash
+pre-commit install
+```
+
+Now, the hooks will automatically run every time you commit changes to the repository. If the hooks find issues, your commit will be blocked until you fix the issues.
+
+Looking to your contributions. Thank you!
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..6b83906
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,59 @@
+FROM python:3.11 as base
+
+ENV HEALTHCHECK_INTERVAL=5m \
+ HEALTHCHECK_TIMEOUT=5s \
+ SETTINGS_DIR=~/.plejd/ \
+ SETTINGS_FILE=settings.yaml \
+ WORKDIR=/app
+
+RUN apt-get update \
+ && apt-get --no-install-recommends install -y bluez=5.66-1 bluetooth=5.66-1 \
+ && rm -rf /var/lib/apt/lists/* \
+ && adduser --disabled-password --gecos '' plejd
+
+WORKDIR $WORKDIR
+RUN chown plejd:plejd $WORKDIR
+
+# Start building stage
+FROM base as builder
+
+USER plejd
+
+RUN pip install --no-cache-dir --user poetry==1.7.1
+
+# Ensure the poetry command is available
+ENV PATH="/home/plejd/.local/bin:${POETRY_HOME}/bin:${PATH}"
+
+# Copy poetry files
+COPY poetry.lock pyproject.toml README.md $WORKDIR/
+
+# Copy application files
+COPY ./plejd_mqtt_ha $WORKDIR/plejd_mqtt_ha
+
+# Install and build dependencies using poetry
+RUN poetry config virtualenvs.in-project true && \
+ poetry install --only=main --no-root && \
+ poetry build
+
+# Start final stage
+FROM base as final
+
+ENV PYTHONPATH="$WORKDIR/.venv/lib/python3.11/site-packages:${PYTHONPATH}"
+
+# Copy the built virtualenv deps from the builder stage
+COPY --from=builder /app/.venv $WORKDIR/.venv
+COPY --from=builder /app/dist $WORKDIR/
+COPY docker-entrypoint.sh $WORKDIR/
+
+RUN ./.venv/bin/pip install "$WORKDIR"/*.whl
+
+# Healthcheck
+COPY healthcheck.py $WORKDIR/healthcheck.py
+HEALTHCHECK --interval=5m --timeout=1s \
+ CMD python $WORKDIR/healthcheck.py || exit 1
+
+# Copy settings file
+COPY $SETTINGS_DIR/$SETTINGS_FILE $WORKDIR/$SETTINGS_FILE
+
+# Set the entrypoint
+ENTRYPOINT ["./docker-entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ed515e2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,200 @@
+# plejd-mqtt-ha
+A simple mqtt gateway to connect your plejd devices to an MQTT broker and Home Assistant
+
+
+
+**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Description](#description)
+- [Getting started](#getting-started)
+ - [Setting Up and Running the Program Locally](#setting-up-and-running-the-program-locally)
+ - [Prerequisites](#prerequisites)
+ - [Installing Python Dependencies](#installing-python-dependencies)
+ - [Activating the Virtual Environment](#activating-the-virtual-environment)
+ - [Running the program](#running-the-program)
+ - [Setting Up and Running as a Docker Container](#setting-up-and-running-as-a-docker-container)
+ - [Building the Docker Image](#building-the-docker-image)
+ - [Running the Docker Container](#running-the-docker-container)
+- [Configuration](#configuration)
+ - [General](#general)
+ - [API](#api)
+ - [MQTT](#mqtt)
+ - [BLE](#ble)
+ - [Example](#example)
+- [Acknowledgements](#acknowledgements)
+- [License](#license)
+- [Contributing](#contributing)
+
+
+
+## Description
+This project connects bluetooth devices in a plejd mesh to an MQTT broker. Devices are discovered automatically by HomeAssistant.
+
+The project currently supports:
+- Plejd Light
+
+Not supported currently:
+- Plejd Switch
+- Plejd Device Trigger
+- Plejd Sensor
+- Plejd scenes
+
+## Getting started
+
+This section provides step-by-step instructions on how to set up and run the program on your local machine.
+
+The project is intended to run as a docker container, but can also be run locally.
+
+### Setting Up and Running the Program Locally
+
+This section provides step-by-step instructions on how to set up and run the program on your local machine.
+
+#### Prerequisites
+
+Ensure that you have the following dependencies installed on your system:
+
+- `python`: The programming language used for this project.
+- `poetry`: A tool for dependency management in Python.
+- `bluetooth`: A library to work with Bluetooth.
+- `bluez`: Official Linux Bluetooth protocol stack.
+
+Refer to the Dockerfile in the project root for specific versions of these dependencies.
+
+#### Installing Python Dependencies
+
+After installing the prerequisites, you can install the Python dependencies for the project. Run the following command in your terminal:
+
+```bash
+poetry install
+```
+#### Activating the Virtual Environment
+Next, spawn a new virtual shell using the following command:
+Start the program using:
+
+```bash
+poetry shell
+```
+
+This command activates the virtual environment, isolating your project dependencies from other Python projects.
+#### Running the program
+
+Finally, you can start the program using the following command:
+
+`python -m plejd_mqtt_ha`
+
+This command runs the plejd_mqtt_ha module as a script, starting the program.
+
+### Setting Up and Running as a Docker Container
+
+This section provides instructions on how to build and run the program as a Docker container. Docker must be installed on your host machine to follow these steps.
+
+#### Building the Docker Image
+
+First, build a Docker image for the program. This creates a reusable image that contains the program and all its dependencies. Run the following command in your terminal:
+
+```bash
+docker build . -t plejd
+```
+This command builds a Docker image from the Dockerfile in the current directory, and tags the image with the name `plejd`
+
+#### Running the Docker Container
+
+After building the image, you can create and start a Docker container from it. Run the following command in your terminal:
+
+```bash
+docker run plejd
+```
+
+This command starts a new Docker container from the plejd image. The program inside the container will start running immediately.
+
+## Configuration
+
+Configuration of the application. See example configuration [here](#example).
+
+### General
+
+| Parameter | Description | Default |
+|------------------------------------------|--------------------------------------------------------|-------------|
+| `health_check` (Optional) | Enable health check | True |
+| `health_check_interval` (Optional) | Interval in seconds between writing health check files | 60.0 |
+| `health_check_dir` (Optional) | Directory to store health check files | "~/.plejd/" |
+| `health_check_bt_file` (Optional) | File name for Bluetooth health check | "bluetooth" |
+| `health_check_mqtt_file` (Optional) | File name for MQTT health check | "mqtt" |
+| `health_check_heartbeat_file` (Optional) | File name for heartbeat file | "heartbeat" |
+
+### API
+
+Settings related to Plejd API.
+
+| Parameter | Description | Default |
+|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|
+| `user` | Plejd user name (email) | |
+| `password` | Password of the Plejd user | |
+| `site` (Optional) | Name of the Plejd site to use | First in the accounts list |
+| `timeout` (Optional) | Timeout to reach Plejd API | 10.0 |
+| `cache_policy` (Optional) | Cache policy to use. Can be one of the following:
- `NO_CACHE`: Does not use cache.
- `FIRST_CACHE`: Caches the plejd site on first run, then uses cache.
- `NEEDED_CACHE`: Uses cache site only when network is not available.
| `FIRST_CACHE` |
+| `cache_dir` (Optional) | Directory to store cached site. Not used if `cache_policy` is set to `NO_CACHE`. | `~/.plejd/` |
+| `cache_file` (Optional) | File name for cached site. Not used if `cache_policy` is set to `NO_CACHE`. | `site.json` |
+
+### MQTT
+
+Settings related to MQTT broker.
+
+| Parameter | Description | Default |
+|----------------------------------|---------------------------------|-----------------|
+| `host` (Optional) | Address of the host MQTT broker | "localhost" |
+| `port` (Optional) | Port of the MQTT host | 1883 |
+| `user` (Optional) | MQTT user name | None |
+| `password` (Optional) | Password of the MQTT user | None |
+| `ha_discovery_prefix` (Optional) | Home assistant discovery prefix | "homeassistant" |
+
+### BLE
+
+Settings related to BLE.
+
+| Parameter | Description | Default |
+|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
+| `adapter` (Optional) | If a specific Bluetooth adapter is to be used. **NOT USED YET!** | None |
+| `scan_time` (Optional) | Time to scan for Plejd Bluetooth devices | 10.0 |
+| `retries` (Optional) | Number of times to try and reconnect to Plejd mesh | 10 |
+| `time_retries` (Optional) | Time between retries | 10.0 |
+| `preferred_device` (Optional) | If a specific Plejd device is to be used as mesh ingress point. If not set, the device with the strongest signal will be used. Not recommended to use this setting. **NOT USED YET!** | None |
+
+### Example
+
+Here is an example configuration with some common settings:
+
+```yaml
+api:
+ user: "your-email@example.com"
+ password: "your-password"
+
+mqtt:
+ host: "mqtt.example.com"
+ port: 1883
+ user: "mqtt-user"
+ password: "mqtt-password"
+
+ble:
+ scan_time: 20.0
+```
+
+## Acknowledgements
+
+This project is inspired by the following repositories:
+
+- [ha-plejd](https://github.com/klali/ha-plejd)
+- [hassio-plejd](https://github.com/icanos/hassio-plejd)
+
+It also utilizes the [ha-mqtt-discoverable](https://github.com/unixorn/ha-mqtt-discoverable) library
+for creating MQTT devices automatically discovered by Home Assistant.
+
+A special thanks to the authors and contributors of these projects for their work and inspiration.
+
+## License
+
+This project is open source and available under the [Apache License Version 2.0](https://www.apache.org/licenses/LICENSE-2.0).
+
+## Contributing
+
+Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for details on how to contribute to this project.
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100755
index 0000000..51b3575
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+
+# Inspired by
+# https://medium.com/omi-uulm/how-to-run-containerized-bluetooth-applications-with-bluez-dced9ab767f6
+
+sudo service dbus start
+sudo service bluetooth start
+
+# We must wait for bluetooth service to start
+msg="Waiting for services to start..."
+time=0
+echo -n "$msg"
+while [[ "$(pidof start-stop-daemon)" != "" ]]; do
+ sleep 1
+ time=$((time + 1))
+ echo -en "\r$msg $time s"
+done
+echo -e "\r$msg done! (in $time s)"
+
+# Reset adapter in case it was stuck from previous session
+hciconfig hci0 down
+hciconfig hci0 up
+
+echo -e "Starting plejd"
+python -m plejd_mqtt_ha
+tail -f /dev/null
diff --git a/healthcheck.py b/healthcheck.py
new file mode 100644
index 0000000..8d289f6
--- /dev/null
+++ b/healthcheck.py
@@ -0,0 +1,201 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Healthcheck program used to check if plejd-mqtt is running."""
+
+import argparse
+import logging
+import sys
+import time
+
+import yaml
+from plejd_mqtt_ha import constants
+from plejd_mqtt_ha.mdl.settings import PlejdSettings
+from pydantic import ValidationError
+
+STATUS_ERR = 1
+STATUS_OK = 0
+
+
+def is_program_running(plejd_settings: PlejdSettings) -> bool:
+ """Check heartbeat and that program is running.
+
+ Parameters
+ ----------
+ plejd_settings : PlejdSettings
+ Settings to use for healthcheck
+
+ Returns
+ -------
+ bool
+ True if program is running, False otherwise
+ """
+ try:
+ # Open the file
+ heartbeat_file = plejd_settings.health_check_dir + plejd_settings.health_check_hearbeat_file
+ with open(heartbeat_file, "r") as f:
+ # Read the file
+ contents = f.read()
+
+ # Convert the contents to a float
+ last_heartbeat = float(contents)
+
+ # Get the current time
+ current_time = time.time()
+
+ # Check if the heartbeat is recent enough
+ if current_time - last_heartbeat < plejd_settings.health_check_interval:
+ return True
+ else:
+ return False
+ except (FileNotFoundError, IOError, ValueError) as e:
+ logging.error(f"Error during healthcheck: {e}")
+ return False
+
+
+def is_blueooth_connected(plejd_settings: PlejdSettings) -> bool:
+ """Check if bluetooth is connected.
+
+ Parameters
+ ----------
+ plejd_settings : PlejdSettings
+ Settings to use for healthcheck
+
+ Returns
+ -------
+ bool
+ True if bluetooth is connected, False otherwise
+ """
+ try:
+ # Open the file
+ bt_status_file = plejd_settings.health_check_dir + plejd_settings.health_check_bt_file
+ with open(bt_status_file, "r") as f:
+ # Read the file
+ contents = f.read()
+
+ # Check if the first word is "connected"
+ if contents.split()[0] == "connected":
+ return True
+ else:
+ return False
+ except (FileNotFoundError, IOError, ValueError) as e:
+ logging.error(f"Error during healthcheck: {e}")
+ return False
+
+
+def is_mqtt_connected(plejd_settings: PlejdSettings) -> bool:
+ """Check if MQTT is connected.
+
+ Parameters
+ ----------
+ plejd_settings : PlejdSettings
+ Settings to use for healthcheck
+
+ Returns
+ -------
+ bool
+ True if MQTT is connected, False otherwise
+ """
+ try:
+ # Open the file
+ mqtt_status_file = plejd_settings.health_check_dir + plejd_settings.health_check_bt_file
+ with open(mqtt_status_file, "r") as f:
+ # Read the file
+ contents = f.read()
+
+ # Check if the first word is "connected"
+ if contents.split()[0] == "connected":
+ return True
+ else:
+ return False
+ except (FileNotFoundError, IOError, ValueError) as e:
+ logging.error(f"Error during healthcheck: {e}")
+ return False
+
+
+def healthcheck(plejd_settings: PlejdSettings) -> int:
+ """Perform healthcheck.
+
+ Parameters
+ ----------
+ plejd_settings : PlejdSettings
+ Settings to use for healthcheck
+
+ Returns
+ -------
+ int
+ STATUS_OK if healthcheck succeeds, STATUS_ERR if it fails
+ """
+ if not is_program_running(plejd_settings):
+ logging.error("Plejd program is not running")
+ return STATUS_ERR
+ if not is_blueooth_connected(plejd_settings):
+ logging.error("Bluetooth is not connected")
+ return STATUS_ERR
+ if not is_mqtt_connected(plejd_settings):
+ logging.error("MQTT is not connected")
+ return STATUS_ERR
+
+ logging.info("All healthcheck passed")
+ return STATUS_OK # All checks passed, return OK
+
+
+def main() -> int:
+ """Entry point for the application script.
+
+ Returns
+ -------
+ int
+ Returns STATUS_OK if healthcheck succeeds, STATUS_ERR if it fails
+
+ Raises
+ ------
+ ValueError
+ If invalid log level is provided
+ """
+ parser = argparse.ArgumentParser(description="Healthcheck program")
+ parser.add_argument("--loglevel", type=str, help="Set log level", default="ERROR")
+ args = parser.parse_args()
+
+ numeric_level = getattr(logging, args.loglevel.upper(), None)
+ if not isinstance(numeric_level, int):
+ raise ValueError(f"Invalid log level: {args.loglevel}")
+
+ logging.basicConfig(level=numeric_level, format="%(asctime)s %(levelname)s %(message)s")
+
+ logging.info("Starting healthcheck")
+
+ logging.info("Loading settings")
+
+ try:
+ with open(constants.settings_file, "r") as file:
+ settings_yaml = yaml.safe_load(file)
+
+ plejd_settings = PlejdSettings.parse_obj(settings_yaml)
+ except FileNotFoundError:
+ logging.critical("The settings.yaml file was not found.")
+ return STATUS_ERR
+ except yaml.YAMLError:
+ logging.critical("There was an error parsing the settings.yaml file.")
+ return STATUS_ERR
+ except ValidationError as e:
+ logging.critical(
+ f"There was an error parsing the settings into a PlejdSettings object: {e}"
+ )
+ return STATUS_ERR
+
+ return healthcheck(plejd_settings)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/plejd_mqtt_ha/__init__.py b/plejd_mqtt_ha/__init__.py
new file mode 100644
index 0000000..b6ee697
--- /dev/null
+++ b/plejd_mqtt_ha/__init__.py
@@ -0,0 +1 @@
+"""The plejd_mqtt_ha module."""
diff --git a/plejd_mqtt_ha/__main__.py b/plejd_mqtt_ha/__main__.py
new file mode 100644
index 0000000..faa4732
--- /dev/null
+++ b/plejd_mqtt_ha/__main__.py
@@ -0,0 +1,28 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Main module."""
+
+import sys
+
+from plejd_mqtt_ha import plejd
+
+
+def main():
+ """Entry point of the application."""
+ sys.exit(plejd.start())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plejd_mqtt_ha/bt_client.py b/plejd_mqtt_ha/bt_client.py
new file mode 100644
index 0000000..1af6e87
--- /dev/null
+++ b/plejd_mqtt_ha/bt_client.py
@@ -0,0 +1,569 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bluetooth client for Plejd mesh.
+
+This module handles the BLE communication with the Plejd mesh. It uses the bleak library to
+communicate with the Plejd mesh.
+"""
+
+import asyncio
+import hashlib
+import logging
+import struct
+from datetime import datetime
+from random import randbytes
+from typing import Any, Callable, Optional
+
+import bleak
+import numpy as np
+from bleak import BleakClient
+from bleak.exc import BleakDBusError, BleakError
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from plejd_mqtt_ha import constants
+from plejd_mqtt_ha.mdl.bt_device_info import BTDeviceInfo
+from plejd_mqtt_ha.mdl.settings import PlejdSettings
+
+
+class PlejdBluetoothError(Exception):
+ """Base class for exceptions in this module."""
+
+ def __init__(self, message: str):
+ """Initialize exception.
+
+ Parameters
+ ----------
+ message : str
+ Error message
+ """
+ self.message = message
+ super().__init__(self.message)
+
+
+class PlejdNotConnectedError(PlejdBluetoothError):
+ """Exception raised for errors if using a Plejd Bluetooth connection when not connected."""
+
+ pass
+
+
+class PlejdTimeoutError(PlejdBluetoothError):
+ """Exception raised for errors when a timeout occurs in Plejd Bluetooth connection."""
+
+ pass
+
+
+class UnsupportedCharacteristicError(PlejdBluetoothError):
+ """Exception raised for errors when trying to use an unsupported characteristic."""
+
+ pass
+
+
+class UnsupportedCommandError(PlejdBluetoothError):
+ """Exception raised for errors when trying to use an unknown command."""
+
+ pass
+
+
+class BTClient:
+ """
+ Plejd bluetooth handler. Connect and interact with the physical devices in your plejd mesh.
+
+ BLE connectivity is handled by SimpleBLE library and its python bindings SimplePYBLE
+ """
+
+ def __init__(self, crypto_key: str, settings: PlejdSettings):
+ """Initialize the BTClient instance.
+
+ Parameters
+ ----------
+ crypto_key : str
+ Crypto key to use for encryption/decryption of data to/from Plejd mesh
+ settings : PlejdSettings
+ Settings for the Plejd mesh
+ """
+ self._client: Optional[BleakClient] = None
+ self._disconnect = False
+ self._settings = settings
+ self._crypto_key = crypto_key
+ self._callbacks: dict = {}
+ self._crypto_key = self._crypto_key.replace("-", "")
+ self._crypto_key = bytes.fromhex(self._crypto_key)
+
+ async def _stay_connected_loop(self) -> None:
+ async def heartbeat():
+ while not self._disconnect:
+ retries = 0
+
+ while retries < self._settings.ble.retries:
+ await asyncio.sleep(self._settings.ble.time_retries)
+
+ if not self.is_connected():
+ await self._connect()
+ retries += 1
+ continue
+ else:
+ if not await self.ping():
+ retries += 1
+ continue
+
+ if retries >= self._settings.ble.retries:
+ logging.warning("Hearbeat timeout")
+
+ await heartbeat()
+
+ async def connect(self, stay_connected: bool) -> bool:
+ """Connect to plejd mesh.
+
+ Parameters
+ ----------
+ stay_connected : bool
+ If True, makes sure user stays conneted as a background task
+
+ Returns
+ -------
+ bool
+ True if connection was successful
+ """
+ logging.info("Connecting to plejd mesh")
+ if not self.is_connected():
+ if not await self._connect():
+ return False
+
+ logging.info("Connected to plejd mesh")
+ if stay_connected:
+ event_loop = asyncio.get_event_loop()
+ event_loop.create_task(self._stay_connected_loop())
+
+ return True
+
+ async def disconnect(self) -> bool:
+ """Disconnect from plejd mesh.
+
+ Returns
+ -------
+ bool
+ True if disconnect was successful
+ """
+ self._disconnect = True
+ if self._client and self.is_connected():
+ return await self._client.disconnect()
+
+ return True
+
+ def is_connected(self) -> bool:
+ """Check if connected to plejd mesh.
+
+ Returns
+ -------
+ bool
+ True if connected to Plejd mesh
+ """
+ return self._client is not None and self._client.is_connected
+
+ async def send_command(
+ self,
+ ble_address: int,
+ command: int,
+ data: str,
+ response_type: int,
+ ) -> None:
+ """Send command to Plejd BLE device.
+
+ Parameters
+ ----------
+ ble_address : int
+ Address of the device to send command to
+ command : int
+ Command to send
+ data : str
+ Data to send
+ response_type : int
+ Response type
+ """
+ if not self.is_connected():
+ error_message = "Trying to send command when not connected to Plejd mesh"
+ logging.error(error_message)
+ raise PlejdNotConnectedError(error_message)
+
+ try:
+ constants.PlejdCommand(command)
+ except ValueError as err:
+ error_message = f"Trying to send unknown command {command}"
+ logging.error(error_message)
+ raise UnsupportedCommandError(error_message) from err
+
+ payload = self._get_cmd_payload(ble_address, command, data, response_type)
+ encoded_address = self._encode_address(self._client.address)
+ encoded_data = self._encrypt_decrypt_data(self._crypto_key, encoded_address, payload)
+
+ try:
+ await self._write_request(constants.PlejdCharacteristic.DATA_UUID.value, encoded_data)
+ except (PlejdBluetoothError, PlejdNotConnectedError, PlejdTimeoutError) as err:
+ logging.error(f"Caught an exception when calling _write_request: {str(err)}")
+ raise
+
+ async def subscribe_last_data(
+ self, ble_address: int, callback: Callable[[bytearray], Any]
+ ) -> None:
+ """Subscribe to last data received from the mesh.
+
+ Parameters
+ ----------
+ ble_address : int
+ Address of the device to listen to
+ callback : Callable[[bytearray],Any]
+ Callback function that shall be invoked on data received by the mesh
+ """
+ if not self.is_connected():
+ error_message = "Trying to subscribe to last data when not connected to Plejd mesh"
+ logging.error(error_message)
+ raise PlejdNotConnectedError(error_message)
+
+ self._callbacks.update({ble_address: callback})
+
+ def _proxy_callback(_, data: bytearray) -> None:
+ if not self._client:
+ return
+ encoded_address = self._encode_address(self._client.address)
+ decrypted_data = self._encrypt_decrypt_data(self._crypto_key, encoded_address, data)
+ # Since it is a mesh one client handles all subscriptions and we need to dispatch to
+ # the correct device
+ sender_addr = decrypted_data[0] # Address of the device that sent data in the mesh
+ if sender_addr not in self._callbacks:
+ logging.warning(
+ f"Received data from device address {sender_addr} but found no registered"
+ "callback"
+ )
+ return
+ self._callbacks[sender_addr](bytearray(decrypted_data)) # Sender callback
+
+ await self._client.start_notify(
+ constants.PlejdCharacteristic.LAST_DATA_UUID.value, _proxy_callback
+ )
+
+ async def ping(self) -> bool:
+ """Ping plejd mesh to check if it is online.
+
+ Returns
+ -------
+ bool
+ True if ping was successful
+ """
+ ping_data = randbytes(1) # any arbitrary payload can be used
+
+ try:
+ await self._write_request(constants.PlejdCharacteristic.PING_UUID.value, ping_data)
+ pong_data = await self._read_request(constants.PlejdCharacteristic.PING_UUID.value)
+ except (PlejdBluetoothError, PlejdNotConnectedError, PlejdTimeoutError) as err:
+ logging.info(f"Ping operation failed: {str(err)}")
+ return False
+
+ if pong_data == bytearray() or not ((ping_data[0] + 1) & 0xFF) == pong_data[0]:
+ return False
+
+ return True
+
+ async def get_last_data(self) -> bytes:
+ """
+ Retrieve last data from plejd mesh. Can for example be used to extract plejd time.
+
+ Returns
+ -------
+ bytes
+ Last data payload
+ """
+ if not self.is_connected():
+ error_message = "Trying to get last data when not connected to Plejd mesh"
+ logging.error(error_message)
+ raise PlejdNotConnectedError(error_message)
+
+ try:
+ encrypted_data = await self._read_request(constants.PlejdCharacteristic.DATA_UUID.value)
+ except (PlejdBluetoothError, PlejdNotConnectedError, PlejdTimeoutError) as err:
+ logging.error(f"Caught an exception when calling _read_request: {str(err)}")
+ raise
+
+ encoded_address = self._encode_address(self._client.address)
+ return self._encrypt_decrypt_data(self._crypto_key, encoded_address, encrypted_data)
+
+ async def get_plejd_time(self, plejd_device: BTDeviceInfo) -> Optional[datetime]:
+ """Request time from plejd mesh.
+
+ Parameters
+ ----------
+ plejd_device : PlejdDevice
+ Plejd device to get time from, can be any device in the mesh. Does not really matter
+ which one.
+
+ Returns
+ -------
+ Optional[datetime]
+ Returns the time in datetime format
+ """
+ if not self.is_connected():
+ error_message = "Trying to get time when not connected to Plejd mesh"
+ logging.error(error_message)
+ raise PlejdNotConnectedError(error_message)
+
+ try:
+ # Request time
+ await self.send_command(
+ plejd_device.ble_address,
+ constants.PlejdCommand.BLE_CMD_TIME_UPDATE.value,
+ "",
+ constants.PlejdResponse.BLE_REQUEST_RESPONSE.value,
+ )
+ # Read respone
+ last_data = await self.get_last_data()
+ except (PlejdBluetoothError, PlejdNotConnectedError, PlejdTimeoutError):
+ logging.error("Failed to read time from Plejd mesh, when calling get_last_data")
+ raise
+
+ # Make sure we receive the time update command
+ if not last_data or not (
+ int.from_bytes(last_data[3:5], "big")
+ == constants.PlejdCommand.BLE_CMD_TIME_UPDATE.value
+ ):
+ logging.warning(
+ "Failed to read time from Plejd mesh, using device: %s",
+ plejd_device.name,
+ )
+ raise UnsupportedCommandError("Received unknown command")
+
+ # Convert from unix timestamp
+ plejd_time = datetime.fromtimestamp(struct.unpack_from(" bool:
+ """Set time in plejd mesh.
+
+ Parameters
+ ----------
+ plejd_device : PlejdDevice
+ Plejd device to use to set time, can be any device in the mesh. Does not really matter
+ which one.
+ time : datetime
+ Time to set in datetime format
+
+ Returns
+ -------
+ bool
+ Boolean status of the operation
+ """
+ timestamp = struct.pack(" bool:
+ self._disconnect = False
+
+ # Scan for Plejd devices
+ try:
+ scanner = bleak.BleakScanner(service_uuids=[constants.PLEJD_SERVICE])
+ await scanner.start()
+ await asyncio.sleep(self._settings.ble.scan_time)
+ await scanner.stop()
+ except (BleakError, BleakDBusError) as err:
+ logging.warning(f"Could not start BLE scanner: {str(err)}")
+ return False
+ except Exception as err:
+ logging.warning(f"Unknown error when starting BLE scanner: {str(err)}")
+ return False
+
+ logging.debug(
+ f"Successfully started BLE scanner, found {len(scanner.discovered_devices)} devices"
+ )
+
+ if not scanner.discovered_devices:
+ logging.warning("Could not find any plejd devices")
+ return False
+
+ # Find device with strongest signal
+ curr_rssi = -255
+ plejd_device = None
+ for [
+ device,
+ advertisement,
+ ] in scanner.discovered_devices_and_advertisement_data.values():
+ if advertisement.rssi > curr_rssi:
+ curr_rssi = advertisement.rssi
+ plejd_device = device
+
+ if not plejd_device:
+ logging.warning("Could not find any plejd devices")
+ return False
+
+ logging.debug(f"Using device {plejd_device} with signal strenth {curr_rssi}")
+
+ # Connect to plejd mesh using the selected device
+ self._client = BleakClient(plejd_device)
+ if not await self._client.connect():
+ logging.warning("Could not connect to plejd device")
+ return False
+
+ # Authenticate to plejd mesh
+ if not await self._auth_challenge_response():
+ logging.warning("Could not authenticate plejd mesh")
+ return False
+
+ return True
+
+ async def _auth_challenge_response(self) -> bool:
+ # Initiate challenge
+ encoded_data = b"\x00"
+ try:
+ await self._write_request(constants.PlejdCharacteristic.AUTH_UUID.value, encoded_data)
+ except (PlejdBluetoothError, PlejdNotConnectedError, PlejdTimeoutError) as err:
+ logging.warning(f"Failed to initiate auth challenge: {str(err)}")
+ return False
+
+ # Read challenge
+ try:
+ challenge = await self._read_request(constants.PlejdCharacteristic.AUTH_UUID.value)
+
+ key_int = int.from_bytes(self._crypto_key, "big")
+ challenge_int = int.from_bytes(challenge, "big")
+
+ intermediate = hashlib.sha256((key_int ^ challenge_int).to_bytes(16, "big")).digest()
+ part1 = int.from_bytes(intermediate[:16], "big")
+ part2 = int.from_bytes(intermediate[16:], "big")
+ response = (part1 ^ part2).to_bytes(16, "big")
+ await self._write_request(constants.PlejdCharacteristic.AUTH_UUID.value, response)
+ except (PlejdBluetoothError, PlejdNotConnectedError, PlejdTimeoutError) as err:
+ logging.warning(f"Failed to perform challenge response: {str(err)}")
+ return False
+
+ return True
+
+ async def _write_request(self, characteristic_uuid, data) -> None:
+ if not self.is_connected():
+ error_message = (
+ f"Trying to write request to characteristic {characteristic_uuid} "
+ " when not connected to Plejd mesh"
+ )
+ logging.error(error_message)
+ raise PlejdNotConnectedError(error_message)
+
+ try:
+ constants.PlejdCharacteristic(characteristic_uuid)
+ except ValueError as err:
+ error_message = (
+ f"Trying to write request to characteristic {characteristic_uuid} "
+ "that is not a valid PlejdCharacteristic"
+ )
+ logging.error(error_message)
+ raise UnsupportedCharacteristicError(error_message) from err
+
+ try:
+ await self._client.write_gatt_char(characteristic_uuid, data)
+ except (BleakError, BleakDBusError) as err:
+ error_message = f"Failed to write to characteristic {characteristic_uuid}: {str(err)}"
+ logging.error(error_message)
+ raise PlejdBluetoothError(error_message) from err
+ except asyncio.TimeoutError as err:
+ error_message = (
+ "Timeout error when writing to characteristic " f"{characteristic_uuid}: {str(err)}"
+ )
+ logging.error(error_message)
+ raise PlejdTimeoutError(error_message) from err
+
+ async def _read_request(self, characteristic_uuid) -> bytearray:
+ if not self.is_connected():
+ error_message = (
+ f"Trying to read request from characteristic {characteristic_uuid} "
+ " when not connected to Plejd mesh"
+ )
+ logging.error(error_message)
+ raise PlejdNotConnectedError(error_message)
+
+ try:
+ constants.PlejdCharacteristic(characteristic_uuid)
+ data = await self._client.read_gatt_char(characteristic_uuid)
+ except ValueError as err:
+ error_message = (
+ f"Trying to read request from characteristic {characteristic_uuid} "
+ "that is not a valid PlejdCharacteristic"
+ )
+ logging.error(error_message)
+ raise UnsupportedCharacteristicError(error_message) from err
+ except (BleakError, BleakDBusError) as err:
+ error_message = f"Failed to read from characteristic {characteristic_uuid}: {str(err)}"
+ logging.error(error_message)
+ raise PlejdBluetoothError(error_message) from err
+ except asyncio.TimeoutError as err:
+ error_message = (
+ "Timeout error when reading from characteristic"
+ f"{characteristic_uuid}: {str(err)}"
+ )
+ logging.error(error_message)
+ raise PlejdTimeoutError(error_message) from err
+
+ return data
+
+ def _encrypt_decrypt_data(self, key: bytes, addr: str, data: bytearray) -> bytes:
+ buf = bytearray(addr * 2)
+ buf += addr[:4]
+
+ # The API requires the use of ECB mode for encryption. This is generally considered
+ # insecure, but it's necessary for compatibility with the API. Bandit will complain about
+ # this, but we can ignore it.
+ cipher = (
+ Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend()) # nosec
+ .encryptor()
+ .update(buf)
+ )
+
+ output = b""
+ for i, byte in enumerate(data):
+ output += struct.pack("B", byte ^ cipher[i % 16])
+
+ return output
+
+ def _encode_address(self, addr: str) -> bytes:
+ ret = bytes.fromhex(addr.replace(":", ""))
+ return ret[::-1]
+
+ def _get_cmd_payload(
+ self, ble_address: int, command: int, hex_str: str, response_type: int
+ ) -> bytearray:
+ buffer_length = 5
+ payload = bytearray(buffer_length)
+
+ struct.pack_into(
+ ">BHH",
+ payload,
+ 0,
+ np.uint8(ble_address),
+ np.ushort(response_type),
+ np.ushort(command),
+ )
+
+ hex_data_bytes = bytearray.fromhex(hex_str)
+ payload.extend(hex_data_bytes)
+
+ return payload
diff --git a/plejd_mqtt_ha/constants.py b/plejd_mqtt_ha/constants.py
new file mode 100644
index 0000000..f62e73c
--- /dev/null
+++ b/plejd_mqtt_ha/constants.py
@@ -0,0 +1,106 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Project wide constants."""
+
+####################################################################################################
+# Plejd API
+####################################################################################################
+
+import os
+from enum import Enum
+
+API_APP_ID = "zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak"
+API_BASE_URL = "https://cloud.plejd.com/parse/"
+API_LOGIN_URL = "login"
+API_SITE_LIST_URL = "functions/getSiteList"
+API_SITE_DETAILS_URL = "functions/getSiteById"
+API_DEFAULT_SITE_NAME = None
+
+####################################################################################################
+# Plejd BLE
+####################################################################################################
+
+# Plejd service
+BLE_UUID_SUFFIX = "6085-4726-be45-040c957391b5"
+PLEJD_SERVICE = "31ba0001-" + BLE_UUID_SUFFIX
+
+
+class PlejdCharacteristic(str, Enum):
+ """Plejd characteristic UIIDs."""
+
+ DATA_UUID = "31ba0004-" + BLE_UUID_SUFFIX
+ LAST_DATA_UUID = "31ba0005-" + BLE_UUID_SUFFIX
+ AUTH_UUID = "31ba0009-" + BLE_UUID_SUFFIX
+ PING_UUID = "31ba000a-" + BLE_UUID_SUFFIX
+
+
+class PlejdCommand(int, Enum):
+ """Plejd BLE commands."""
+
+ BLE_CMD_DIM_CHANGE = 0x00C8
+ BLE_CMD_DIM2_CHANGE = 0x0098
+ BLE_CMD_STATE_CHANGE = 0x0097
+ BLE_CMD_SCENE_TRIG = 0x0021
+ BLE_CMD_TIME_UPDATE = 0x001B
+ BLE_CMD_REMOTE_CLICK = 0x0016
+
+
+class PlejdLightAction(str, Enum):
+ """BLE payload for possible actions on a light."""
+
+ BLE_DEVICE_ON = "01"
+ BLE_DEVICE_OFF = "00"
+ BLE_DEVICE_DIM = "01"
+
+
+class PlejdResponse(int, Enum):
+ """Possible Plejd device responses."""
+
+ BLE_REQUEST_NO_RESPONSE = 0x0110
+ BLE_REQUEST_RESPONSE = 0x0102
+
+
+####################################################################################################
+# Plejd MISC
+####################################################################################################
+
+
+# Traits
+class PlejdTraits(int, Enum):
+ """Plejd device traits."""
+
+ NO_LOAD = 0
+ NON_DIMMABLE = 9 # TODO: Not used yet
+ DIMMABLE = 11 # TODO: Not used yet
+
+
+# Device types
+class PlejdType(str, Enum):
+ """Plejd device types."""
+
+ SWITCH = "switch"
+ LIGHT = "light"
+ SENSOR = "sensor"
+ DEVICE_TRIGGER = "device_automation"
+ UNKNOWN = "unknown"
+
+
+####################################################################################################
+# Project related constants
+####################################################################################################
+
+settings_dir: str = os.path.expanduser("~/.plejd/")
+
+settings_file: str = os.path.join(settings_dir, "settings.yaml")
diff --git a/plejd_mqtt_ha/mdl/__init__.py b/plejd_mqtt_ha/mdl/__init__.py
new file mode 100644
index 0000000..e562863
--- /dev/null
+++ b/plejd_mqtt_ha/mdl/__init__.py
@@ -0,0 +1 @@
+"""The plejd_mqtt_ha model package."""
diff --git a/plejd_mqtt_ha/mdl/bt_data_type.py b/plejd_mqtt_ha/mdl/bt_data_type.py
new file mode 100644
index 0000000..7434ce3
--- /dev/null
+++ b/plejd_mqtt_ha/mdl/bt_data_type.py
@@ -0,0 +1,44 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Type to hold parsed data coming from a plejd device.
+
+All new BT devices should add a new class here, inheriting from BTData.
+"""
+
+from pydantic import BaseModel
+
+
+class BTData(BaseModel):
+ """Type to hold parsed data coming from a plejd device."""
+
+ raw_data: bytearray
+ """Raw, decrypted data from the Plejd device
+ """
+
+ class Config:
+ """Pydantic BaseModel configuration."""
+
+ arbitrary_types_allowed = True # Allow for bytearray
+
+
+class BTLightData(BTData):
+ """Parsed data type coming from a plejd light."""
+
+ state: bool
+ """State of the light, True = light is ON
+ """
+ brightness: int
+ """Brightness of the Plejd light
+ """
diff --git a/plejd_mqtt_ha/mdl/bt_device.py b/plejd_mqtt_ha/mdl/bt_device.py
new file mode 100644
index 0000000..a3bb6f5
--- /dev/null
+++ b/plejd_mqtt_ha/mdl/bt_device.py
@@ -0,0 +1,213 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Plejd bluetooth device.
+
+All new BT devices should add a new class here, inheriting from BTDevice.
+"""
+
+import logging
+from typing import Callable, Generic, TypeVar
+
+from plejd_mqtt_ha import constants
+from plejd_mqtt_ha.bt_client import (
+ BTClient,
+ PlejdBluetoothError,
+ PlejdNotConnectedError,
+ PlejdTimeoutError,
+)
+from plejd_mqtt_ha.mdl.bt_data_type import BTData, BTLightData
+from plejd_mqtt_ha.mdl.bt_device_info import BTDeviceInfo, BTLightInfo
+
+PlejdDeviceTypeT = TypeVar("PlejdDeviceTypeT", bound=BTDeviceInfo)
+
+
+class BTDevice(Generic[PlejdDeviceTypeT]):
+ """Plejd bluetooth device super class."""
+
+ def __init__(self, bt_client: BTClient, device_info: PlejdDeviceTypeT) -> None:
+ """Initialize the BTDevice instance.
+
+ Parameters
+ ----------
+ bt_client : BTClient
+ BTClient instance to use for communication
+ device_info : PlejdDeviceTypeT
+ Device info for the device
+ """
+ self._device_info = device_info
+ self._plejd_bt_client = bt_client
+
+ async def subscribe(self, callback: Callable[[BTData], None]) -> bool:
+ """Subscribe to changes for the device.
+
+ Parameters
+ ----------
+ callback : Callable[[PlejdResponseType], None]
+ Callback to be invoked when data is received
+
+ Returns
+ -------
+ bool
+ Boolean status of the operation
+ """
+
+ def _proxy_callback(decrypted_data: bytearray) -> None:
+ callback(self._decode_response(decrypted_data)) # return parsed data
+
+ return await self._plejd_bt_client.subscribe_last_data(
+ self._device_info.ble_address, _proxy_callback
+ )
+
+ def _decode_response(self, decrypted_data: bytearray) -> BTLightData:
+ """Device specific decoding to be implemented by subclass, ie device class.
+
+ Parameters
+ ----------
+ decrypted_data : bytearray
+ Decrypted data coming from Plejd device
+
+ Raises
+ ------
+ NotImplementedError
+ In case it's not implemented in subclass but a callback is provided
+ """
+ raise NotImplementedError
+
+
+class BTLight(BTDevice[BTLightInfo]):
+ """Plejd bluetooth light device."""
+
+ async def on(self) -> bool:
+ """Turn on a physical Plejd Light.
+
+ Returns
+ -------
+ bool
+ True if successful, False otherwise
+ """
+ logging.debug(f"Turning on device {self._device_info.name}")
+ try:
+ await self._plejd_bt_client.send_command(
+ self._device_info.ble_address,
+ constants.PlejdCommand.BLE_CMD_STATE_CHANGE,
+ constants.PlejdLightAction.BLE_DEVICE_ON,
+ constants.PlejdResponse.BLE_REQUEST_NO_RESPONSE,
+ )
+ except PlejdNotConnectedError as err:
+ logging.warning(
+ f"Device {self._device_info.name} is not connected, cannot turn on."
+ f"Error: {str(err)}"
+ )
+ return False
+ except (PlejdBluetoothError, PlejdTimeoutError) as err:
+ logging.warning(
+ f"Failed to turn on device {self._device_info.name}, due to bluetooth a error."
+ f"Error: {str(err)}"
+ )
+ return False
+
+ return True
+
+ async def off(self) -> bool:
+ """Turn off a physical Plejd Light.
+
+ Returns
+ -------
+ bool
+ True if successful, False otherwise
+ """
+ logging.debug(f"Turning off device {self._device_info.name}")
+
+ try:
+ await self._plejd_bt_client.send_command(
+ self._device_info.ble_address,
+ constants.PlejdCommand.BLE_CMD_STATE_CHANGE,
+ constants.PlejdLightAction.BLE_DEVICE_OFF,
+ constants.PlejdResponse.BLE_REQUEST_NO_RESPONSE,
+ )
+ except PlejdNotConnectedError as err:
+ logging.warning(
+ f"Device {self._device_info.name} is not connected, cannot turn on."
+ f"Error: {str(err)}"
+ )
+ return False
+ except (PlejdBluetoothError, PlejdTimeoutError) as err:
+ logging.warning(
+ f"Failed to turn on device {self._device_info.name}, due to bluetooth a error."
+ f"Error: {str(err)}"
+ )
+ return False
+
+ return True
+
+ async def brightness(self, brightness: int) -> bool:
+ """Set brightness of a Plejd Light.
+
+ Parameters
+ ----------
+ brightness : int
+ Brightness to set, 0-255
+
+ Returns
+ -------
+ bool
+ True if successful, False otherwise
+ """
+ logging.debug(f"Setting brightness of device {self._device_info.name}")
+
+ if not self._device_info.brightness:
+ raise RuntimeError(
+ f"Device {self._device_info.name} does not support setting brightness"
+ )
+
+ pad = "0" if brightness <= 0xF else ""
+ s_brightness = brightness << 8 | brightness
+ data = constants.PlejdLightAction.BLE_DEVICE_DIM + pad + f"{s_brightness:X}"
+ try:
+ await self._plejd_bt_client.send_command(
+ self._device_info.ble_address,
+ constants.PlejdCommand.BLE_CMD_DIM2_CHANGE,
+ data,
+ constants.PlejdResponse.BLE_REQUEST_NO_RESPONSE,
+ )
+ except PlejdNotConnectedError as err:
+ logging.warning(
+ f"Device {self._device_info.name} is not connected, cannot turn on."
+ f"Error: {str(err)}"
+ )
+ return False
+ except (PlejdBluetoothError, PlejdTimeoutError) as err:
+ logging.warning(
+ f"Failed to turn on device {self._device_info.name}, due to bluetooth a error."
+ f"Error: {str(err)}"
+ )
+ return False
+
+ return True
+
+ def _decode_response(self, decrypted_data: bytearray) -> BTLightData:
+ # Overriden
+
+ state = decrypted_data[5] if len(decrypted_data) > 5 else 0
+ brightness = decrypted_data[7] if len(decrypted_data) > 7 else 0
+
+ brightness = int(brightness)
+ state = bool(state)
+ response = BTLightData(
+ raw_data=decrypted_data,
+ state=state,
+ brightness=brightness,
+ )
+ return response
diff --git a/plejd_mqtt_ha/mdl/bt_device_info.py b/plejd_mqtt_ha/mdl/bt_device_info.py
new file mode 100644
index 0000000..bf042be
--- /dev/null
+++ b/plejd_mqtt_ha/mdl/bt_device_info.py
@@ -0,0 +1,63 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Type to hold information about a Plejd device.
+
+All new BT devices should add a new class here, inheriting from BTDeviceInfo.
+"""
+
+from typing import Optional
+
+from plejd_mqtt_ha import constants
+from pydantic import BaseModel
+
+
+class BTDeviceInfo(BaseModel):
+ """Base class that defines device information common to all plejd devices."""
+
+ category: str
+ """Type of device category, ie light etc"""
+ supported_commands: Optional[list] = None
+ """List of Plejd BLE commands to listen to, device type specific"""
+
+ model: str
+ """Model of the device"""
+ device_id: str
+ """Identity of the device, as stated in the Plejd API"""
+ unique_id: str
+ """Unique identity of the device, required by HA"""
+ name: str
+ """Name of the device"""
+ hardware_id: str # TODO rename to device address?
+ """Adress of the device within the Plejd mesh"""
+ index: int # TODO this is the index of the entity actually
+ """Index of the entity belonging to the device"""
+ ble_address: int
+ """BLE address of the device"""
+ firmware_version: Optional[str] = None
+ """Firmware version of the device"""
+
+
+class BTLightInfo(BTDeviceInfo):
+ """Information specific to light devices."""
+
+ category = "light"
+ supported_commands = [
+ constants.PlejdCommand.BLE_CMD_DIM2_CHANGE,
+ constants.PlejdCommand.BLE_CMD_DIM_CHANGE,
+ constants.PlejdCommand.BLE_CMD_STATE_CHANGE,
+ ]
+
+ brightness: bool = False
+ """Whether or not the light supports setting brightness"""
diff --git a/plejd_mqtt_ha/mdl/combined_device.py b/plejd_mqtt_ha/mdl/combined_device.py
new file mode 100644
index 0000000..8bc66da
--- /dev/null
+++ b/plejd_mqtt_ha/mdl/combined_device.py
@@ -0,0 +1,250 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Combined device module, combines a Plejd BT device with an MQTT device.
+
+All new devices should be added here, inheriting from CombinedDevice.
+"""
+
+import asyncio
+import json
+import logging
+from typing import Generic, Optional, TypeVar
+
+from ha_mqtt_discoverable import DeviceInfo, Discoverable, Settings
+from ha_mqtt_discoverable.sensors import Light, LightInfo, Subscriber
+from paho.mqtt.client import Client, MQTTMessage
+from plejd_mqtt_ha.bt_client import BTClient, PlejdNotConnectedError
+from plejd_mqtt_ha.mdl.bt_data_type import BTLightData
+from plejd_mqtt_ha.mdl.bt_device import BTDevice, BTLight
+from plejd_mqtt_ha.mdl.bt_device_info import BTDeviceInfo, BTLightInfo
+from plejd_mqtt_ha.mdl.settings import PlejdSettings
+
+PlejdDeviceTypeT = TypeVar("PlejdDeviceTypeT", bound=BTDeviceInfo)
+MQTTDeviceTypeT = TypeVar("MQTTDeviceTypeT", bound=Discoverable)
+
+
+class CombinedDeviceError(Exception):
+ """Combined device error."""
+
+ def __init__(self, message: str) -> None:
+ """Initialize the CombinedDeviceError instance.
+
+ Parameters
+ ----------
+ message : str
+ Error message
+ """
+ self.message = message
+ super().__init__(self.message)
+
+
+class MQTTDeviceError(CombinedDeviceError):
+ """MQTT device error."""
+
+ pass
+
+
+class BTDeviceError(CombinedDeviceError):
+ """MQTT device error."""
+
+ pass
+
+
+class CombinedDevice(Generic[PlejdDeviceTypeT]):
+ """A combined device that connects a Plejd BT device to an MQTT devices."""
+
+ def __init__(
+ self,
+ bt_client: BTClient,
+ settings: PlejdSettings,
+ device_info: PlejdDeviceTypeT,
+ ) -> None:
+ """Initialize the CombinedDevice instance.
+
+ Parameters
+ ----------
+ bt_client : BTClient
+ BTClient instance to use for communication
+ settings : PlejdSettings
+ Settings for the device
+ device_info : PlejdDeviceTypeT
+ Device info for the device
+ """
+ self._device_info = device_info
+ self._settings = settings
+ self._plejd_bt_client = bt_client
+ self._event_loop = asyncio.get_event_loop()
+ self._mqtt_device = None
+ self._bt_device: Optional[BTDevice] = None
+
+ async def start(self) -> None:
+ """Start the combined device.
+
+ This will register it with HA and connect it to the physical device.
+ """
+ try:
+ self._mqtt_device = self._create_mqtt_device()
+ except ConnectionError as err:
+ error_msg = f"Failed to connect to MQTT broker for {self._device_info.name}"
+ logging.error(error_msg)
+ raise MQTTDeviceError(error_msg) from err
+ except RuntimeError as err:
+ error_msg = f"Failed to create MQTT device for {self._device_info.name}"
+ logging.error(error_msg)
+ raise MQTTDeviceError(error_msg) from err
+
+ try:
+ self._bt_device = await self._create_bt_device()
+ except BTDeviceError as err:
+ error_msg = f"Failed to create BT device for {self._device_info.name}"
+ logging.error(error_msg)
+ raise BTDeviceError(error_msg) from err
+
+ def _create_mqtt_device(self) -> Subscriber[PlejdDeviceTypeT]:
+ """Create an MQTT device, shall be overriden in all subclasses.
+
+ If a callbac is needed, use the _mqtt_callback function.
+
+ Returns
+ -------
+ Any
+ The created MQTT device
+
+ Raises
+ ------
+ NotImplementedError
+ Must be implemented in subclass, otherwise we raise an exception
+ """
+ raise NotImplementedError
+
+ async def _create_bt_device(self) -> BTDevice:
+ """Create a Plejd BT mesh device, shall be overriden in all subclasses.
+
+ Returns
+ -------
+ Optional[BTDevice]
+ The created Plejd BT mesh device
+
+ Raises
+ ------
+ NotImplementedError
+ Must be implemented in subclass, otherwise we raise an exception
+ """
+ raise NotImplementedError
+
+ def _mqtt_callback(self, client: Client, user_data, message: MQTTMessage) -> None:
+ """MQTT device callback, shall be implemented by subclass if an MQTT callback is needed.
+
+ Parameters
+ ----------
+ client : Client
+ MQTT client
+ user_data : _type_
+ Optional user data
+ message : MQTTMessage
+ Received MQTT message
+
+ Raises
+ ------
+ NotImplementedError
+ If used and not implemented, raise an exception
+ """
+ raise NotImplementedError
+
+ def _bt_callback(self, light_response: BTLightData) -> None:
+ """Plejd BT mesh device callback, shall be implemented if subclass needs a callback.
+
+ Parameters
+ ----------
+ light_response : PlejdLightResponse
+ Data coming from Plejd BT mesh device
+
+ Raises
+ ------
+ NotImplementedError
+ If used and not implemented, raise an exception
+ """
+ raise NotImplementedError
+
+
+# TODO create subclass from device category automatically??? Possible?
+class CombinedLight(CombinedDevice[BTLightInfo]):
+ """A combined Plejd BT and MQTT light."""
+
+ def _create_mqtt_device(self) -> Subscriber[BTLightInfo]:
+ # Override
+ mqtt_device_info = DeviceInfo(
+ name=self._device_info.name, identifiers=self._device_info.unique_id
+ )
+ supported_color_modes = None
+ if self._device_info.brightness:
+ supported_color_modes = ["brightness"]
+
+ mqtt_light_info = LightInfo(
+ name=self._device_info.name,
+ brightness=self._device_info.brightness,
+ color_mode=self._device_info.brightness,
+ supported_color_modes=supported_color_modes,
+ unique_id=self._device_info.unique_id + "_1",
+ device=mqtt_device_info,
+ )
+ settings = Settings(mqtt=self._settings.mqtt, entity=mqtt_light_info)
+ return Light(settings=settings, command_callback=self._mqtt_callback)
+
+ async def _create_bt_device(self) -> BTDevice:
+ # Override
+ bt_light = BTLight(bt_client=self._plejd_bt_client, device_info=self._device_info)
+ logging.info(f"Subscribing to BT device {self._device_info.name}")
+
+ try:
+ await bt_light.subscribe(self._bt_callback)
+ except PlejdNotConnectedError as err:
+ error_message = f"Failed to subscribe to BT data for device {self._device_info.name}"
+ logging.error(error_message)
+ raise BTDeviceError(error_message) from err
+
+ return bt_light
+
+ def _mqtt_callback(self, client: Client, user_data, message: MQTTMessage) -> None:
+ # Override
+ payload = json.loads(message.payload.decode())
+ if not self._bt_device:
+ logging.info(f"BT device {self._device_info.name} not created yet")
+ return
+ if "brightness" in payload:
+ if asyncio.run_coroutine_threadsafe(
+ self._bt_device.brightness(payload["brightness"]), loop=self._event_loop
+ ):
+ self._mqtt_device.brightness(payload["brightness"]) # TODO generics?
+ elif "state" in payload:
+ if payload["state"] == "ON":
+ if asyncio.run_coroutine_threadsafe(self._bt_device.on(), loop=self._event_loop):
+ self._mqtt_device.on()
+ else:
+ if asyncio.run_coroutine_threadsafe(self._bt_device.off(), loop=self._event_loop):
+ self._mqtt_device.off()
+ else:
+ logging.warning(f"Unknown payload {payload}")
+
+ def _bt_callback(self, light_response: BTLightData) -> None:
+ # Override
+ if not self._mqtt_device:
+ logging.info(f"MQTT device {self._device_info.name} not created yet")
+ return
+
+ if light_response.state:
+ self._mqtt_device.brightness(light_response.brightness)
+ else:
+ self._mqtt_device.off()
diff --git a/plejd_mqtt_ha/mdl/settings.py b/plejd_mqtt_ha/mdl/settings.py
new file mode 100644
index 0000000..27e621c
--- /dev/null
+++ b/plejd_mqtt_ha/mdl/settings.py
@@ -0,0 +1,110 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Settings module for Plejd MQTT HA.
+
+Settings are loaded from a YAML file, the default location is ~/.plejd/settings.yaml
+"""
+
+
+import os
+from typing import Optional
+
+from pydantic import BaseModel, validator
+
+
+class API(BaseModel):
+ """Settings related to Plejd API."""
+
+ user: str
+ """Plejd user name (email)"""
+ password: str
+ """Password of the plejd user"""
+ site: Optional[str] = None
+ """Name of the plejd site to use, default is the first in the accounts list"""
+ timeout: float = 10.0
+ """Timeout to reach Plejd API"""
+ cache_policy: str = "FIRST_CACHE"
+ """
+ Cache policy to use. Can be one of the following:
+ - "NO_CACHE": Does not use cache.
+ - "FIRST_CACHE": Caches the plejd site on first run, then uses cache.
+ - "NEEDED_CACHE": Uses cached site only when network is not available.
+ """
+ cache_dir: str = os.path.expanduser("~/.plejd/")
+ """Directory to store cached site, not used if cache_policy is set to NO_CACHE"""
+ cache_file: str = "site.json"
+ """File name for cached site, not used if cache_policy is set to NO_CACHE"""
+
+ @validator("cache_policy")
+ def cache_policy_is_valid(cls, v):
+ """Validate cache policy."""
+ if v not in ["NO_CACHE", "FIRST_CACHE", "NEEDED_CACHE"]:
+ raise ValueError("Invalid cache policy")
+ return v
+
+
+class MQTT(BaseModel):
+ """Settings related to MQTT broker."""
+
+ host: str = "localhost"
+ """Address of the host MQTT broker"""
+ port: int = 1883
+ """Port of the MQTT host"""
+ user: Optional[str] = None
+ """MQTT user name"""
+ password: Optional[str] = None
+ """Password of the MQTT user"""
+ ha_discovery_prefix: str = "homeassistant"
+ """Home assistant discovery prefix"""
+
+
+class BLE(BaseModel):
+ """Settings related to BLE."""
+
+ adapter: Optional[str] = None # TODO: implement
+ """If a specific bluetooth adapter is to be used"""
+ preferred_device: Optional[str] = None # TODO: implement
+ """If a specific Plejd device is to be used as mesh ingress point, if not set the device with
+ the strongest signal will be used. Not recommended to use this setting"""
+ scan_time: float = 10.0
+ """Time to scan for plejd bluetooth devices"""
+ retries: int = 10
+ """Number of times to try and reconnect to Plejd mesh"""
+ time_retries: float = 10.0
+ """Time between retries"""
+
+
+class PlejdSettings(BaseModel):
+ """Single class containing all settings."""
+
+ api: API
+ """Plejd API settings"""
+ mqtt: MQTT = MQTT()
+ """MQTT settings"""
+ ble: BLE = BLE()
+ """BLE settings"""
+
+ health_check: bool = True
+ """Enable health check"""
+ health_check_interval: float = 60.0
+ """Interval in seconds between writing health check files"""
+ health_check_dir: str = os.path.expanduser("~/.plejd/")
+ """Directory to store health check files"""
+ health_check_bt_file: str = "bluetooth"
+ """File name for bluetooth health check"""
+ health_check_mqtt_file: str = "mqtt"
+ """File name for MQTT health check"""
+ health_check_hearbeat_file: str = "heartbeat"
+ """File name for heartbeat file"""
diff --git a/plejd_mqtt_ha/mdl/site.py b/plejd_mqtt_ha/mdl/site.py
new file mode 100644
index 0000000..840a95f
--- /dev/null
+++ b/plejd_mqtt_ha/mdl/site.py
@@ -0,0 +1,31 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Type to hold parsed data from plejd cloud, containing information about an entire site."""
+
+from plejd_mqtt_ha.mdl.bt_device_info import BTDeviceInfo
+from pydantic import BaseModel
+
+
+class PlejdSite(BaseModel):
+ """Holds important data related to a Plejd site."""
+
+ name: str
+ """Name of the site"""
+ site_id: str
+ """Identity of the site"""
+ crypto_key: str
+ """Crypto key used to authenticate against the plejd mesh"""
+ devices: list[BTDeviceInfo]
+ """List of devices belonging to the site"""
diff --git a/plejd_mqtt_ha/plejd.py b/plejd_mqtt_ha/plejd.py
new file mode 100644
index 0000000..37cedc3
--- /dev/null
+++ b/plejd_mqtt_ha/plejd.py
@@ -0,0 +1,232 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Main plejd module.
+
+Responsible for starting loading settings, performing health checks and creating devices.
+"""
+
+import asyncio
+import logging
+import os
+import sys
+import time
+
+import yaml
+from plejd_mqtt_ha import constants
+from plejd_mqtt_ha.bt_client import BTClient
+from plejd_mqtt_ha.mdl.combined_device import (
+ BTDeviceError,
+ CombinedDevice,
+ CombinedLight,
+ MQTTDeviceError,
+)
+from plejd_mqtt_ha.mdl.settings import PlejdSettings
+from plejd_mqtt_ha.mdl.site import PlejdSite
+from plejd_mqtt_ha.plejd_api import IncorrectCredentialsError, PlejdAPI, PlejdAPIError
+from pydantic import ValidationError
+
+
+def start() -> None:
+ """Start the Plejd service.
+
+ This function will never return unless program exits, for whatever reason.
+ """
+ logging.basicConfig(level=logging.INFO)
+ try:
+ asyncio.run(_run())
+ except Exception as ex:
+ logging.critical("Unhandled exception occured, exiting")
+ logging.critical(ex)
+ sys.exit()
+ finally:
+ pass # TODO: Cleanup
+
+
+async def _run() -> None:
+ """Entry point for starting and running the program."""
+ try:
+ with open(constants.settings_file, "r") as file:
+ settings_yaml = yaml.safe_load(file)
+
+ plejd_settings = PlejdSettings.parse_obj(settings_yaml)
+ except FileNotFoundError:
+ logging.critical("The settings.yaml file was not found.")
+ return
+ except yaml.YAMLError:
+ logging.critical("There was an error parsing the settings.yaml file.")
+ return
+ except ValidationError as e:
+ logging.critical(
+ f"There was an error parsing the settings into a PlejdSettings object: {e}"
+ )
+ return
+
+ logging.info("Loaded Plejd settings.. [OK]")
+
+ try:
+ plejd_site = await _load_plejd_site(plejd_settings)
+ except IncorrectCredentialsError as err:
+ logging.critical(str(err))
+ return # exit program if incorrect credentials
+
+ logging.info("Plejd site loaded.. [OK]")
+
+ plejd_bt_client = await _create_bt_client(plejd_site.crypto_key, plejd_settings)
+
+ logging.info("Created Plejd BT client.. [OK]")
+ discovered_devices = await _create_devices(plejd_settings, plejd_bt_client, plejd_site)
+ if len(discovered_devices) <= 0: # No devices created
+ logging.critical("Failed to create Plejd devices, exiting")
+ return
+ logging.info(f"Created {len(discovered_devices)} Plejd devices.. [OK]")
+
+ heartbeat_task = asyncio.create_task(
+ write_health_data(plejd_settings, discovered_devices)
+ ) # Create heartbeat task
+
+ await heartbeat_task # Wait indefinitely for the heartbeat task
+
+
+async def write_health_data(
+ plejd_settings: PlejdSettings, discovered_devices: list[CombinedDevice]
+) -> None:
+ """Write health data to files.
+
+ Will continously write health data to files in the health_check_dir. This is used by the docker
+ healthcheck script.
+
+ Parameters
+ ----------
+ plejd_settings : PlejdSettings
+ Settings
+ discovered_devices : list[CombinedDevice]
+ List of discovered devices
+ """
+ first_device = discovered_devices[0] # Get first device
+ bt_client = first_device._plejd_bt_client # Get BT client
+ mqtt_client = first_device._mqtt_device.mqtt_client # Get MQTT client
+
+ # Write bluetooth health check file
+ bt_health_check_file = plejd_settings.health_check_dir + plejd_settings.health_check_bt_file
+ mqtt_health_check_file = plejd_settings.health_check_dir + plejd_settings.health_check_mqtt_file
+ heartbeat_file = plejd_settings.health_check_dir + plejd_settings.health_check_hearbeat_file
+
+ while True: # Run forever
+ # Retrieve status of BT and MQTT clients
+ if bt_client.is_connected() and await bt_client.ping():
+ bt_status = "connected"
+ else:
+ bt_status = "disconnected"
+ if mqtt_client.is_connected():
+ mqtt_status = "connected"
+ else:
+ mqtt_status = "disconnected"
+
+ try:
+ if plejd_settings.health_check:
+ if not os.path.exists(plejd_settings.health_check_dir):
+ os.makedirs(plejd_settings.health_check_dir)
+ # Indicate that the program is running
+ with open(heartbeat_file, "w") as f:
+ f.write(str(time.time()))
+ # Indicate that we are connected to Plejd bluetooth mesh
+ with open(bt_health_check_file, "w") as f:
+ f.write(bt_status)
+ # Indicate that we are connected to broker
+ with open(mqtt_health_check_file, "w") as f:
+ f.write(mqtt_status)
+ else:
+ pass # Do nothing if health check is disabled
+ except (IOError, FileNotFoundError) as e:
+ logging.error(f"Error writing to healthcheck file: {e}")
+ finally:
+ await asyncio.sleep(plejd_settings.health_check_interval)
+
+
+async def _load_plejd_site(settings: PlejdSettings) -> PlejdSite:
+ # Load Plejd site from API, retry until success, or exit if credentials are incorrect
+ api = PlejdAPI(settings)
+
+ try:
+ plejd_site = api.get_site()
+ except IncorrectCredentialsError:
+ logging.critical("Failed to login to Plejd API, incorrect credentials in settings.json")
+ raise
+ except PlejdAPIError as err: # Any other error, retry forever
+ logging.error(f"Failed to retreive Plejd site: {str(err)})")
+
+ async def _api_retry_loop() -> PlejdSite:
+ logging.info("Entering Plejd API retry loop")
+ while True:
+ try:
+ return api.get_site()
+ except PlejdAPIError as err:
+ logging.error(f"Failed to login to Plejd API: {str(err)})")
+ await asyncio.sleep(10) # TODO: HC value, add backoff?
+
+ plejd_site = await _api_retry_loop() # retry until success
+
+ logging.debug("Successfully logged in to Plejd API")
+
+ return plejd_site
+
+
+async def _create_bt_client(crypto_key: str, settings: PlejdSettings) -> BTClient:
+ # Create Plejd BT client, retry until success
+
+ bt_client = BTClient(crypto_key, settings)
+ stay_connected = True
+
+ if not await bt_client.connect(stay_connected):
+
+ async def _bt_retry_loop() -> None:
+ logging.info("Entering Plejd BT retry loop")
+ while True:
+ if await bt_client.connect(stay_connected):
+ return
+ await asyncio.sleep(10) # TODO: HC value, add backoff?
+
+ await _bt_retry_loop()
+
+ return bt_client
+
+
+async def _create_devices(
+ settings: PlejdSettings, bt_client: BTClient, plejd_site: PlejdSite
+) -> list[CombinedDevice]:
+ # Create every device connected to the Plejd Account
+
+ combined_devices = []
+ for device in plejd_site.devices:
+ logging.info(f"Creating device {device.name}")
+ if device.category == "light": # TODO HC for now
+ combined_device = CombinedLight(
+ bt_client=bt_client, settings=settings, device_info=device
+ )
+ try:
+ await combined_device.start()
+ combined_devices.append(combined_device)
+ except MQTTDeviceError as err:
+ logging.warning(
+ f"Skipping device {device.name}, cant create MQTT device: {str(err)}"
+ )
+ continue
+ except BTDeviceError as err:
+ logging.warning(f"Skipping device {device.name}, cant create BT device: {str(err)}")
+ continue
+ else:
+ logging.warning(f"{device.category} not supported")
+ continue
+ return combined_devices
diff --git a/plejd_mqtt_ha/plejd_api.py b/plejd_mqtt_ha/plejd_api.py
new file mode 100644
index 0000000..a25d865
--- /dev/null
+++ b/plejd_mqtt_ha/plejd_api.py
@@ -0,0 +1,659 @@
+# Copyright 2023 Viktor Karlquist
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Plejd API module.
+
+Handles all communication with the Plejd cloud platform.
+"""
+
+import json
+import logging
+from typing import Optional
+
+import plejd_mqtt_ha.constants
+import requests
+from plejd_mqtt_ha.mdl.bt_device_info import BTDeviceInfo, BTLightInfo
+from plejd_mqtt_ha.mdl.settings import PlejdSettings
+from plejd_mqtt_ha.mdl.site import PlejdSite
+from pydantic import BaseModel
+
+
+class PlejdAPIError(Exception):
+ """Exception raised for errors in the Plejd Bluetooth connection."""
+
+ def __init__(self, message: str):
+ """Initialize PlejdAPIError class.
+
+ Parameters
+ ----------
+ message : str
+ Error message
+ """
+ self.message = message
+ super().__init__(self.message)
+
+
+class UnknownResponseError(PlejdAPIError):
+ """Exception raised when the Plejd API returns an unknown response."""
+
+ pass
+
+
+class UnsupportedDeviceTypeError(PlejdAPIError):
+ """Exception raised when the Plejd API returns an unsupported device type."""
+
+ pass
+
+
+class IncorrectCredentialsError(PlejdAPIError):
+ """Exception raised when the supplied credentials are incorrect."""
+
+ pass
+
+
+class CacheFileError(PlejdAPIError):
+ """Exception raised when cache file cannot be loaded or stored."""
+
+ pass
+
+
+class PlejdAPI:
+ """PlejdAPI class that handles all communication with the Plejd cloud platform.
+
+ Data does not need to be fetched unless devices has been added and/or modified, ie data can be
+ cached locally.
+ """
+
+ class PlejdDeviceType(BaseModel):
+ """Types of devices from Plejd API."""
+
+ name: str
+ """Name of the specific type
+ """
+ device_category: str
+ """Which type of device category does it belong to
+ """
+ dimmable: bool = False
+ """Whether or not the device can be dimmed
+ """
+ broadcast_clicks: bool = False
+ """Whether or not it can broadcast clicks, ie a button etc
+ """
+
+ def __init__(self, settings: PlejdSettings) -> None:
+ """Initialize PlejdAPI class.
+
+ Parameters
+ ----------
+ settings : PlejdSettings
+ Settings for the Plejd API
+ """
+ self._settings = settings.api
+ self._session_token = None
+
+ def login(self) -> None:
+ """Login to plejd API. This is required to get the site information."""
+ headers = {
+ "X-Parse-Application-Id": plejd_mqtt_ha.constants.API_APP_ID,
+ "Content-Type": "application/json",
+ }
+
+ data = {"username": self._settings.user, "password": self._settings.password}
+
+ try:
+ response = requests.post(
+ plejd_mqtt_ha.constants.API_BASE_URL + plejd_mqtt_ha.constants.API_LOGIN_URL,
+ json=data,
+ headers=headers,
+ timeout=self._settings.timeout,
+ )
+ response.raise_for_status()
+ response_json = response.json()
+ if "sessionToken" not in response_json:
+ raise ValueError("Failed to parse response as JSON")
+ except requests.HTTPError as err:
+ if response.status_code == 401:
+ logging.error("Login failed due to incorrect credentials")
+ raise IncorrectCredentialsError(
+ "Login failed due to incorrect credentials"
+ ) from err
+ else:
+ logging.error(f"HTTP error occurred: {str(err)}")
+ raise PlejdAPIError(f"HTTP error occurred: {str(err)}") from err
+ except requests.RequestException as err:
+ logging.error(f"Login request failed: {str(err)}")
+ raise PlejdAPIError(f"Login request failed: {str(err)}") from err
+ except ValueError as err:
+ logging.error(f"Login parse failed: {str(err)}")
+ raise UnknownResponseError(f"Login parse failed: {str(err)}") from err
+
+ self._session_token = response_json["sessionToken"]
+
+ def get_site(self) -> PlejdSite:
+ """Get plejd site.
+
+ If no site name in settings, it will take the first site in the list. Also responsible for
+ logging in to the API if not already logged in.
+
+ Returns
+ -------
+ PlejdSite
+ Plejd site with site_name or first plejd site in list
+
+ Raises
+ ------
+ PlejdAPIError
+ If the request fails
+ UnknownResponseError
+ If the response is not as expected
+ CacheFileError
+ If the cache file is not found
+ IncorrectCredentialsError
+ If the credentials are incorrect to login to the Plejd API
+ """
+ # Check cache policy
+ if self._settings.cache_policy == "FIRST_CACHE":
+ # Fetch from cache if exists, otherwise use cache
+ logging.info("Fetching site from cache if possible, otherwise using API")
+ try:
+ plejd_site = self._get_site_from_cache()
+ except CacheFileError as err:
+ logging.warning(f"Failed to get site from cache: {str(err)}")
+ logging.info("Fetching site from Plejd API")
+ try:
+ plejd_site = self._get_site_from_api()
+ except (IncorrectCredentialsError, PlejdAPIError, UnknownResponseError) as err:
+ logging.error(f"Failed to get site from Plejd API: {str(err)}")
+ raise
+ elif self._settings.cache_policy == "NO_CACHE":
+ # Fetch from API, otherwise raise error
+ logging.info("Fetching site from Plejd API")
+ try:
+ # Attempt to fetch from API, do not store site afterwards
+ plejd_site = self._get_site_from_api()
+ except (IncorrectCredentialsError, PlejdAPIError, UnknownResponseError) as err:
+ logging.warning(f"Failed to get site from API: {str(err)}")
+ raise
+ elif self._settings.cache_policy == "NEEDED_CACHE":
+ # Fetch from API, otherwise use cache
+ logging.info("Fetching site from API if possible, otherwise using cache")
+ try:
+ plejd_site = self._get_site_from_api()
+ except (IncorrectCredentialsError, PlejdAPIError, UnknownResponseError) as err:
+ logging.warning(f"Failed to get site from API: {str(err)}")
+ logging.info("Fetching site from cache")
+ try:
+ plejd_site = self._get_site_from_cache()
+ except CacheFileError as err:
+ logging.error(f"Failed to get site from cache: {str(err)}")
+ raise
+
+ return plejd_site
+
+ def _get_site_from_cache(self) -> PlejdSite:
+ """Get site from cache.
+
+ Returns
+ -------
+ PlejdSite
+ Plejd site
+ """
+ json_site = self._get_json_site_from_cache()
+
+ plejd_site = PlejdSite(
+ name=json_site["site"]["title"],
+ site_id=json_site["site"]["siteId"],
+ crypto_key=json_site["plejdMesh"]["cryptoKey"],
+ devices=self._get_devices(json_site),
+ )
+
+ return plejd_site
+
+ def _get_site_from_api(self) -> PlejdSite:
+ """Get site from Plejd API.
+
+ Returns
+ -------
+ PlejdSite
+ Plejd site
+ """
+ self.login()
+ site_data = self._get_site_data()
+ site_id = self._get_site_id(site_data, self._settings.site)
+ json_site = self._get_json_site_from_api(site_id)
+
+ try:
+ self.store_json_site_to_cache(json_site)
+ except CacheFileError as err:
+ # Only log error if not able to store site to cache, ok to continue
+ logging.warning(f"Failed to store site to cache: {str(err)}")
+
+ plejd_site = PlejdSite(
+ name=json_site["site"]["title"],
+ site_id=site_id,
+ crypto_key=json_site["plejdMesh"]["cryptoKey"],
+ devices=self._get_devices(json_site),
+ )
+
+ return plejd_site
+
+ def _get_json_site_from_api(self, site_id: str) -> dict:
+ """Get json site data from Plejd API.
+
+ Parameters
+ ----------
+ site_id : str
+ Id of the site to fetch JSON content from
+
+ Returns
+ -------
+ str:
+ JSON content of the site
+ """
+ headers = {
+ "X-Parse-Application-Id": plejd_mqtt_ha.constants.API_APP_ID,
+ "X-Parse-Session-Token": self._session_token,
+ "Content-Type": "application/json",
+ }
+
+ data = {
+ "siteId": site_id,
+ }
+
+ response = requests.post(
+ plejd_mqtt_ha.constants.API_BASE_URL + plejd_mqtt_ha.constants.API_SITE_DETAILS_URL,
+ json=data,
+ headers=headers,
+ timeout=10,
+ )
+ try:
+ response_json = response.json()
+ if "result" not in response_json:
+ raise UnknownResponseError(f"Failed to get site: {response_json}")
+ logging.info("Got site from Plejd API")
+ except (ValueError, UnknownResponseError) as err:
+ logging.error(f"Failed to parse response as JSON: {str(err)}")
+ raise UnknownResponseError(f"Failed to parse response as JSON: {str(err)}") from err
+
+ return response_json["result"][0]
+
+ def _get_site_id(self, site_data: dict, site_name: Optional[str]) -> str:
+ """Get site id from site data.
+
+ Will return the first site if no site name is specified in settings.
+
+ Parameters
+ ----------
+ site_data : dict
+ JSON content of the site
+ site_name : Optional[str]
+ Name of the site to fetch, by default None
+
+ Returns
+ -------
+ str
+ site id
+ """
+ # Fetch site ID and name
+ site_id = ""
+ if site_name is not None:
+ for site in site_data:
+ if site["site"]["title"] == site_name:
+ site_id = site["site"]["siteId"]
+ break
+ if not site_id:
+ old_site_name = site_name # Store for warning message
+ site_name = site_data["result"][0]["site"]["title"]
+ site_id = site_data["result"][0]["site"]["site"]["siteId"]
+ logging.warning(
+ f"Site {old_site_name} not found in available sites, using {site_name}"
+ )
+ else:
+ site_id = site_data[0]["site"]["siteId"]
+ return site_id
+
+ def _get_site_data(self) -> dict:
+ """Get site data from Plejd API.
+
+ Site data contains information about all available sites.
+
+ Returns
+ -------
+ dict
+ JSON content of the site
+
+ Raises
+ ------
+ PlejdAPIError
+ If the request fails
+ UnknownResponseError
+ If the response is not as expected
+ """
+ # Fetch site information from Plejd API
+ headers = {
+ "X-Parse-Application-Id": plejd_mqtt_ha.constants.API_APP_ID,
+ "X-Parse-Session-Token": self._session_token,
+ "Content-Type": "application/json",
+ }
+ try:
+ response = requests.post(
+ plejd_mqtt_ha.constants.API_BASE_URL + plejd_mqtt_ha.constants.API_SITE_LIST_URL,
+ headers=headers,
+ timeout=10,
+ )
+ response.raise_for_status()
+ response_json = response.json()
+ if "result" not in response_json:
+ raise UnknownResponseError("")
+ except requests.RequestException as err:
+ logging.error(f"Failed to get site: {str(err)}")
+ raise PlejdAPIError(f"Failed to get site: {str(err)}") from err
+ except (ValueError, UnknownResponseError) as err:
+ logging.error(f"Failed to parse site response: {str(err)}")
+ raise UnknownResponseError(f"Failed to parse site response: {str(err)}") from err
+
+ return response_json["result"]
+
+ def _get_json_site_from_cache(self) -> dict:
+ """Get site from cache.
+
+ Returns
+ -------
+ dict
+ JSON content of the site
+ """
+ try:
+ with open(self._settings.cache_dir + self._settings.cache_file, "r") as file:
+ json_site = json.load(file)
+ logging.info("Loaded site from cache")
+ except (FileNotFoundError, IsADirectoryError, PermissionError, IOError) as err:
+ error_message = f"Failed to read cache file: {str(err)}"
+ logging.error(error_message)
+ raise CacheFileError(error_message) from err
+ return json_site
+
+ def store_json_site_to_cache(self, json_site: dict) -> None:
+ """Store site to cache.
+
+ Parameters
+ ----------
+ json_site : dict
+ JSON content of the site
+ """
+ try:
+ with open(self._settings.cache_dir + self._settings.cache_file, "w") as file:
+ json.dump(json_site, file)
+ except (FileNotFoundError, IsADirectoryError, PermissionError, IOError) as err:
+ error_message = f"Failed to write cache file: {str(err)}"
+ logging.error(error_message)
+ raise CacheFileError(error_message) from err
+
+ def _get_devices(self, json_res: dict) -> list[BTDeviceInfo]:
+ """Get all devices from Plejd API.
+
+ Parameters
+ ----------
+ json_res : dict
+ JSON content of the site
+
+ Returns
+ -------
+ list[BTDeviceInfo]
+ List of all devices
+ """
+ device_list = []
+ plejd_devices = json_res["plejdDevices"]
+ output_address = json_res["outputAddress"]
+ device_address = json_res["deviceAddress"]
+ output_settings_list = json_res["outputSettings"]
+ input_settings_list = json_res["inputSettings"]
+ for device in json_res["devices"]: # Get all necessary plejd device details
+ device_id = device["deviceId"]
+ device_name = device["title"]
+ device_hardware_id = device_address[device_id]
+
+ try:
+ device_type = self._get_device_type(device_hardware_id)
+ except UnsupportedDeviceTypeError as err:
+ logging.warning(f"Failed to get device type for {device_name}: {str(err)}")
+ continue
+
+ devices = list(filter(lambda x: x["deviceId"] == device["deviceId"], plejd_devices))[:1]
+ if len(devices) > 0:
+ device_firmware_version = devices[0]["firmware"]["version"]
+ else:
+ logging.warning(f"Could not determine FW version for device: {device_name}")
+ device_firmware_version = "xxx"
+
+ output_settings = list(
+ filter(
+ lambda x: x["deviceParseId"] == device["objectId"],
+ output_settings_list,
+ )
+ )[:1]
+ input_settings = list(
+ filter(lambda x: x["deviceId"] == device["deviceId"], input_settings_list)
+ )
+
+ if len(output_settings) > 0: # It's an output device
+ if device_id not in output_address:
+ logging.warning(
+ f"Device: {device_name} does not exist as output address, skipping device"
+ )
+ continue
+
+ if device["traits"] == plejd_mqtt_ha.constants.PlejdTraits.NO_LOAD.value:
+ logging.warning(
+ f"No load settings found for output device: {device_name}, skipping device"
+ )
+ continue
+
+ device_index = output_settings[0][
+ "output"
+ ] # TODO is this really the correct index??
+ device_ble_address = output_address[device_id][str(device_index)]
+ # Append output index to device id for uniqueness
+ device_unique_id = device_id + f"_{device_index}"
+ elif len(input_settings) > 0: # It's an input device
+ if device_id not in device_address:
+ logging.warning(
+ f"Device: {device_name} does not exist as device address, skipping"
+ )
+ continue
+
+ device_ble_address = device_address[device_id]
+ for input_setting in input_settings:
+ if not device_type.broadcast_clicks:
+ logging.info(
+ f"Input device: {device_name} does not broadcast clicks, skipping"
+ )
+ continue
+
+ device_index = input_setting["input"] # TODO is this really the correct index??
+ # Append input index to device id for uniqueness
+ device_unique_id = device_id + f"_{device_index}"
+ else:
+ logging.warning(
+ f"Device: {device_name} is neither output nor input device, skipping"
+ )
+ continue
+
+ # Parse type and create device info
+ if device_type.device_category == plejd_mqtt_ha.constants.PlejdType.LIGHT.value:
+ plejd_device = BTLightInfo(
+ model=device_type.name,
+ type=plejd_mqtt_ha.constants.PlejdType.LIGHT.value,
+ device_id=device_id,
+ unique_id=device_unique_id,
+ name=device_name,
+ hardware_id=device_hardware_id,
+ index=device_index,
+ ble_address=device_ble_address,
+ category=device_type.device_category,
+ firmware_version=device_firmware_version,
+ brightness=device_type.dimmable,
+ )
+ elif device_type.device_category == plejd_mqtt_ha.constants.PlejdType.SWITCH.value:
+ # TODO create
+ logging.warning(
+ f"Usnupported device category {plejd_mqtt_ha.constants.PlejdType.SWITCH.value}"
+ )
+ continue
+ elif device_type.device_category == plejd_mqtt_ha.constants.PlejdType.SENSOR.value:
+ # TODO
+ logging.warning(
+ f"Usnupported device category {plejd_mqtt_ha.constants.PlejdType.SENSOR.value}"
+ )
+ continue
+ elif (
+ device_type.device_category
+ == plejd_mqtt_ha.constants.PlejdType.DEVICE_TRIGGER.value
+ ):
+ # TODO
+ logging.warning(
+ "Usnupported device category "
+ f"{plejd_mqtt_ha.constants.PlejdType.DEVICE_TRIGGER.value}"
+ )
+ continue
+ else:
+ continue
+
+ device_list.append(plejd_device)
+
+ return device_list
+
+ def _get_device_type(self, hardware_id: int) -> PlejdDeviceType:
+ # Get type of device by hardware id
+ if hardware_id in {1, 11, 14}:
+ return self.PlejdDeviceType(
+ name="DIM-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.LIGHT.value,
+ dimmable=True,
+ broadcast_clicks=False,
+ )
+ if hardware_id in {2, 15}:
+ return self.PlejdDeviceType(
+ name="DIM-02",
+ device_category=plejd_mqtt_ha.constants.PlejdType.LIGHT.value,
+ dimmable=True,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 3:
+ return self.PlejdDeviceType(
+ name="CTR-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.LIGHT.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 4:
+ return self.PlejdDeviceType(
+ name="GWY-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.SENSOR.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 5:
+ return self.PlejdDeviceType(
+ name="LED-10",
+ device_category=plejd_mqtt_ha.constants.PlejdType.LIGHT.value,
+ dimmable=True,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 6:
+ return self.PlejdDeviceType(
+ name="WPH-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.DEVICE_TRIGGER.value,
+ dimmable=False,
+ broadcast_clicks=True,
+ )
+ if hardware_id == 7:
+ return self.PlejdDeviceType(
+ name="REL-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.SWITCH.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 8:
+ return self.PlejdDeviceType(
+ name="SPR-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.SWITCH.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 9:
+ return self.PlejdDeviceType(
+ name="WRT-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.DEVICE_TRIGGER.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 10:
+ return self.PlejdDeviceType(
+ name="WRT-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.DEVICE_TRIGGER.value,
+ dimmable=False,
+ broadcast_clicks=True,
+ )
+ if hardware_id == 12:
+ return self.PlejdDeviceType(
+ name="DAL-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.LIGHT.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 13:
+ return self.PlejdDeviceType(
+ name="Generic",
+ device_category=plejd_mqtt_ha.constants.PlejdType.DEVICE_TRIGGER.value,
+ dimmable=False,
+ broadcast_clicks=True,
+ )
+ if hardware_id == 16:
+ return self.PlejdDeviceType(
+ name="-unknown-",
+ device_category=plejd_mqtt_ha.constants.PlejdType.DEVICE_TRIGGER.value,
+ dimmable=False,
+ broadcast_clicks=True,
+ )
+ if hardware_id == 17:
+ return self.PlejdDeviceType(
+ name="REL-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.SWITCH.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 18:
+ return self.PlejdDeviceType(
+ name="REL-02",
+ device_category=plejd_mqtt_ha.constants.PlejdType.SWITCH.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 19:
+ return self.PlejdDeviceType(
+ name="-unknown-",
+ device_category=plejd_mqtt_ha.constants.PlejdType.LIGHT.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ if hardware_id == 20:
+ return self.PlejdDeviceType(
+ name="SPR-01",
+ device_category=plejd_mqtt_ha.constants.PlejdType.SWITCH.value,
+ dimmable=False,
+ broadcast_clicks=False,
+ )
+ error_message = f"Failed to get device type for device id {hardware_id}"
+ logging.error(error_message)
+ raise UnsupportedDeviceTypeError(error_message)
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..963ee48
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1596 @@
+# This file is automatically @generated by Poetry 1.4.1 and should not be changed by hand.
+
+[[package]]
+name = "astroid"
+version = "2.15.8"
+description = "An abstract syntax tree for Python with inference support."
+category = "dev"
+optional = false
+python-versions = ">=3.7.2"
+files = [
+ {file = "astroid-2.15.8-py3-none-any.whl", hash = "sha256:1aa149fc5c6589e3d0ece885b4491acd80af4f087baafa3fb5203b113e68cd3c"},
+ {file = "astroid-2.15.8.tar.gz", hash = "sha256:6c107453dffee9055899705de3c9ead36e74119cee151e5a9aaf7f0b0e020a6a"},
+]
+
+[package.dependencies]
+lazy-object-proxy = ">=1.4.0"
+wrapt = {version = ">=1.14,<2", markers = "python_version >= \"3.11\""}
+
+[[package]]
+name = "bleak"
+version = "0.21.1"
+description = "Bluetooth Low Energy platform Agnostic Klient"
+category = "main"
+optional = false
+python-versions = ">=3.8,<3.13"
+files = [
+ {file = "bleak-0.21.1-py3-none-any.whl", hash = "sha256:ccec260a0f5ec02dd133d68b0351c0151b2ecf3ddd0bcabc4c04a1cdd7f33256"},
+ {file = "bleak-0.21.1.tar.gz", hash = "sha256:ec4a1a2772fb315b992cbaa1153070c7e26968a52b0e2727035f443a1af5c18f"},
+]
+
+[package.dependencies]
+bleak-winrt = {version = ">=1.2.0,<2.0.0", markers = "platform_system == \"Windows\" and python_version < \"3.12\""}
+dbus-fast = {version = ">=1.83.0,<3", markers = "platform_system == \"Linux\""}
+pyobjc-core = {version = ">=9.2,<10.0", markers = "platform_system == \"Darwin\""}
+pyobjc-framework-CoreBluetooth = {version = ">=9.2,<10.0", markers = "platform_system == \"Darwin\""}
+pyobjc-framework-libdispatch = {version = ">=9.2,<10.0", markers = "platform_system == \"Darwin\""}
+typing-extensions = {version = ">=4.7.0", markers = "python_version < \"3.12\""}
+"winrt-Windows.Devices.Bluetooth" = {version = "2.0.0b1", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
+"winrt-Windows.Devices.Bluetooth.Advertisement" = {version = "2.0.0b1", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
+"winrt-Windows.Devices.Bluetooth.GenericAttributeProfile" = {version = "2.0.0b1", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
+"winrt-Windows.Devices.Enumeration" = {version = "2.0.0b1", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
+"winrt-Windows.Foundation" = {version = "2.0.0b1", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
+"winrt-Windows.Foundation.Collections" = {version = "2.0.0b1", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
+"winrt-Windows.Storage.Streams" = {version = "2.0.0b1", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
+
+[[package]]
+name = "bleak-winrt"
+version = "1.2.0"
+description = "Python WinRT bindings for Bleak"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+ {file = "bleak-winrt-1.2.0.tar.gz", hash = "sha256:0577d070251b9354fc6c45ffac57e39341ebb08ead014b1bdbd43e211d2ce1d6"},
+ {file = "bleak_winrt-1.2.0-cp310-cp310-win32.whl", hash = "sha256:a2ae3054d6843ae0cfd3b94c83293a1dfd5804393977dd69bde91cb5099fc47c"},
+ {file = "bleak_winrt-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:677df51dc825c6657b3ae94f00bd09b8ab88422b40d6a7bdbf7972a63bc44e9a"},
+ {file = "bleak_winrt-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9449cdb942f22c9892bc1ada99e2ccce9bea8a8af1493e81fefb6de2cb3a7b80"},
+ {file = "bleak_winrt-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:98c1b5a6a6c431ac7f76aa4285b752fe14a1c626bd8a1dfa56f66173ff120bee"},
+ {file = "bleak_winrt-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:623ac511696e1f58d83cb9c431e32f613395f2199b3db7f125a3d872cab968a4"},
+ {file = "bleak_winrt-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:13ab06dec55469cf51a2c187be7b630a7a2922e1ea9ac1998135974a7239b1e3"},
+ {file = "bleak_winrt-1.2.0-cp38-cp38-win32.whl", hash = "sha256:5a36ff8cd53068c01a795a75d2c13054ddc5f99ce6de62c1a97cd343fc4d0727"},
+ {file = "bleak_winrt-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:810c00726653a962256b7acd8edf81ab9e4a3c66e936a342ce4aec7dbd3a7263"},
+ {file = "bleak_winrt-1.2.0-cp39-cp39-win32.whl", hash = "sha256:dd740047a08925bde54bec357391fcee595d7b8ca0c74c87170a5cbc3f97aa0a"},
+ {file = "bleak_winrt-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:63130c11acfe75c504a79c01f9919e87f009f5e742bfc7b7a5c2a9c72bf591a7"},
+]
+
+[[package]]
+name = "cerberus"
+version = "1.3.5"
+description = "Lightweight, extensible schema and data validation tool for Pythondictionaries."
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+ {file = "Cerberus-1.3.5-py3-none-any.whl", hash = "sha256:7649a5815024d18eb7c6aa5e7a95355c649a53aacfc9b050e9d0bf6bfa2af372"},
+ {file = "Cerberus-1.3.5.tar.gz", hash = "sha256:81011e10266ef71b6ec6d50e60171258a5b134d69f8fb387d16e4936d0d47642"},
+]
+
+[[package]]
+name = "certifi"
+version = "2023.11.17"
+description = "Python package for providing Mozilla's CA Bundle."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"},
+ {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"},
+]
+
+[[package]]
+name = "cffi"
+version = "1.16.0"
+description = "Foreign Function Interface for Python calling C code."
+category = "main"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"},
+ {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"},
+ {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"},
+ {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"},
+ {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"},
+ {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"},
+ {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"},
+ {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"},
+ {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"},
+ {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"},
+ {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"},
+ {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"},
+ {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"},
+ {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"},
+ {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"},
+]
+
+[package.dependencies]
+pycparser = "*"
+
+[[package]]
+name = "charset-normalizer"
+version = "3.3.2"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+category = "main"
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
+ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "cryptography"
+version = "41.0.7"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf"},
+ {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d"},
+ {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a"},
+ {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15"},
+ {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a"},
+ {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1"},
+ {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157"},
+ {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406"},
+ {file = "cryptography-41.0.7-cp37-abi3-win32.whl", hash = "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d"},
+ {file = "cryptography-41.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2"},
+ {file = "cryptography-41.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960"},
+ {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003"},
+ {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7"},
+ {file = "cryptography-41.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec"},
+ {file = "cryptography-41.0.7-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be"},
+ {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a"},
+ {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c"},
+ {file = "cryptography-41.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a"},
+ {file = "cryptography-41.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39"},
+ {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a"},
+ {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248"},
+ {file = "cryptography-41.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309"},
+ {file = "cryptography-41.0.7.tar.gz", hash = "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc"},
+]
+
+[package.dependencies]
+cffi = ">=1.12"
+
+[package.extras]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
+docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
+nox = ["nox"]
+pep8test = ["black", "check-sdist", "mypy", "ruff"]
+sdist = ["build"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test-randomorder = ["pytest-randomly"]
+
+[[package]]
+name = "dateutils"
+version = "0.6.12"
+description = "Various utilities for working with date and datetime objects"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+ {file = "dateutils-0.6.12-py2.py3-none-any.whl", hash = "sha256:f33b6ab430fa4166e7e9cb8b21ee9f6c9843c48df1a964466f52c79b2a8d53b3"},
+ {file = "dateutils-0.6.12.tar.gz", hash = "sha256:03dd90bcb21541bd4eb4b013637e4f1b5f944881c46cc6e4b67a6059e370e3f1"},
+]
+
+[package.dependencies]
+python-dateutil = "*"
+pytz = "*"
+
+[[package]]
+name = "dbus-fast"
+version = "2.20.0"
+description = "A faster version of dbus-next"
+category = "main"
+optional = false
+python-versions = ">=3.7,<4.0"
+files = [
+ {file = "dbus_fast-2.20.0-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:ecf22e22434bdd61bfb8b544eb58f5032b23dda5a7fc233afa1d3c9c3241f0a8"},
+ {file = "dbus_fast-2.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed70f4c1fe23c47a59d81c8fd8830c65307a1f089cc92949004df4c65c69f155"},
+ {file = "dbus_fast-2.20.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:9963180456586d0e1b58075e0439a34ed8e9ee4266b35f76f3db6ffc1af17e27"},
+ {file = "dbus_fast-2.20.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:eafbf4f0ac86fd959f86bbdf910bf64406b35315781014ef4a1cd2bb43985346"},
+ {file = "dbus_fast-2.20.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bb668e2039e15f0e5af14bee7de8c8c082e3b292ed2ce2ceb3168c7068ff2856"},
+ {file = "dbus_fast-2.20.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:3f7966f835da1d8a77c55a7336313bd97e7f722b316f760077c55c1e9533b0cd"},
+ {file = "dbus_fast-2.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff856cbb1508bcf6735ed1e3c04de1def6c400720765141d2470e39c8fd6f13"},
+ {file = "dbus_fast-2.20.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7a1da4ed9880046403ddedb7b941fd981872fc883dc9925bbf269b551f12120d"},
+ {file = "dbus_fast-2.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9084ded47761a43b2252879c6ebaddb7e3cf89377cbdc981de7e8ba87c845239"},
+ {file = "dbus_fast-2.20.0-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:d4b91b98cc1849f7d062d563d320594377b099ea9de53ebb789bf9fd6a0eeab4"},
+ {file = "dbus_fast-2.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8ab58ef76575e6e00cf1c1f5747b24ce19e35d4966f1c5c3732cea2c3ed5e9"},
+ {file = "dbus_fast-2.20.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1909addfad23d400d6f77c3665778a96003e32a1cddd1964de605d0ca400d829"},
+ {file = "dbus_fast-2.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e591b218d4f327df29a89a922f199bbefb6f892ddc9b96aff21c05c15c0e5dc8"},
+ {file = "dbus_fast-2.20.0-cp37-cp37m-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:f55f75ac3891c161daeabdb37d8a3407098482fe54013342a340cdd58f2be091"},
+ {file = "dbus_fast-2.20.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d317dba76e904f75146ce0c5f219dae44e8060767b3adf78c94557bbcbea2cbe"},
+ {file = "dbus_fast-2.20.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:88126343024f280c1fadd6599ac4cd7046ed550ddc942811dc3d290830cffd51"},
+ {file = "dbus_fast-2.20.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ecc07860e3014607a5293e1b87294148f96b1cc508f6496b27e40f64079ebb7a"},
+ {file = "dbus_fast-2.20.0-cp38-cp38-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:e9cdf34f81320b36ce7f2b8c46169632730d9cdcafc52b55cada95096fce3457"},
+ {file = "dbus_fast-2.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e40ad43412f92373e4c74bb76d2129a7f0c38a1d883adcfc08f168535f7e7846"},
+ {file = "dbus_fast-2.20.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4a5fdebcd8f79d417693536d3ed08bb5842917d373fbc3e9685feecd001accd7"},
+ {file = "dbus_fast-2.20.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b134d40688ca7f27ab38bec99194e2551c82fc01f583f44ae66129c3d15db8a7"},
+ {file = "dbus_fast-2.20.0-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:fdd4ece2c856e44b5fe9dec354ce5d8930f7ae9bb4b96b3a195157621fea6322"},
+ {file = "dbus_fast-2.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e609309d5503a5eab91a7b0cef9dd158c3d8786ac38643a962e99a69d5eb7a66"},
+ {file = "dbus_fast-2.20.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8fd806bf4676a28b2323d8529d51f86fec5a9d32923d53ba522a4c2bc3d55857"},
+ {file = "dbus_fast-2.20.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8526ff5b27b7c689d97fe8a29e97d3cb7298419b4cb63ed9029331d08d423c55"},
+ {file = "dbus_fast-2.20.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:562868206d774080c4131b124a407350ffb5d2b89442048350b83b5084f4e0e1"},
+ {file = "dbus_fast-2.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707fc61b4f2de83c8f574061fdaf0ac6fc28b402f451951cf0a1ead11bfcac71"},
+ {file = "dbus_fast-2.20.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:4a13c7856459e849202165fd9e1adda8169107a591b083b95842c15b9e772be4"},
+ {file = "dbus_fast-2.20.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca1aba69c1dd694399124efbc6ce15930e4697a95d527f16b614100f1f1055a2"},
+ {file = "dbus_fast-2.20.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:9817bd32d7734766b073bb08525b9560b0b9501c68c43cc91d43684a2829ad86"},
+ {file = "dbus_fast-2.20.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bed226cccedee0c94b292e27fd1c7d24987d36b5ac1cde021031f9c77a76a423"},
+ {file = "dbus_fast-2.20.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:c11a2b4addb965e09a2d8d666265455f4a7e48916b7c6f43629b828de6682425"},
+ {file = "dbus_fast-2.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88367c2a849234f134b9c98fdb16dc84d5ba9703fe995c67f7306900bfa13896"},
+ {file = "dbus_fast-2.20.0.tar.gz", hash = "sha256:a38e837c5a8d0a1745ec8390f68ff57986ed2167b0aa2e4a79738a51dd6dfcc3"},
+]
+
+[[package]]
+name = "dill"
+version = "0.3.7"
+description = "serialize all of Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "dill-0.3.7-py3-none-any.whl", hash = "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e"},
+ {file = "dill-0.3.7.tar.gz", hash = "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03"},
+]
+
+[package.extras]
+graph = ["objgraph (>=1.7.2)"]
+
+[[package]]
+name = "distlib"
+version = "0.3.7"
+description = "Distribution utilities"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+ {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"},
+ {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"},
+]
+
+[[package]]
+name = "docopt"
+version = "0.6.2"
+description = "Pythonic argument parser, that will make you smile"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
+]
+
+[[package]]
+name = "gitlike-commands"
+version = "0.2.1"
+description = ""
+category = "main"
+optional = false
+python-versions = ">=3.9,<4.0"
+files = [
+ {file = "gitlike-commands-0.2.1.tar.gz", hash = "sha256:0e930493b36f970d41b10a692d94f0024fdd73ff35a264c0366b4363cccf5907"},
+ {file = "gitlike_commands-0.2.1-py3-none-any.whl", hash = "sha256:f0a7da49eb9ee5da1171f4a19ca5abecfaeebfd1fb87b0a16a552ca825c44eb7"},
+]
+
+[[package]]
+name = "ha-mqtt-discoverable"
+version = "0.12.0"
+description = ""
+category = "main"
+optional = false
+python-versions = ">=3.10.0,<4.0"
+files = [
+ {file = "ha_mqtt_discoverable-0.12.0-py3-none-any.whl", hash = "sha256:2b8d322fa5dd2f87d9aec969815b7bf60ef0db6ac12b6ca2aed1cf3f1c5be7f7"},
+ {file = "ha_mqtt_discoverable-0.12.0.tar.gz", hash = "sha256:331684180e0d39d87b9b4a52c7aeb594fffff737f5f880fd20a74001eec90856"},
+]
+
+[package.dependencies]
+gitlike-commands = ">=0.2.1,<0.3.0"
+paho-mqtt = ">=1.6.1,<2.0.0"
+pyaml = ">=21.10.1,<22.0.0"
+pydantic = ">=1.10.5,<2.0.0"
+thelogrus = ">=0.7.0,<0.8.0"
+
+[[package]]
+name = "idna"
+version = "3.6"
+description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"},
+ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "isort"
+version = "5.13.0"
+description = "A Python utility / library to sort Python imports."
+category = "dev"
+optional = false
+python-versions = ">=3.8.0"
+files = [
+ {file = "isort-5.13.0-py3-none-any.whl", hash = "sha256:15e0e937819b350bc256a7ae13bb25f4fe4f8871a0bc335b20c3627dba33f458"},
+ {file = "isort-5.13.0.tar.gz", hash = "sha256:d67f78c6a1715f224cca46b29d740037bdb6eea15323a133e897cda15876147b"},
+]
+
+[package.dependencies]
+pip-api = "*"
+pipreqs = "*"
+requirementslib = "*"
+
+[package.extras]
+colors = ["colorama (>=0.4.6)"]
+plugins = ["setuptools"]
+
+[[package]]
+name = "lazy-object-proxy"
+version = "1.9.0"
+description = "A fast and thorough lazy object proxy."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "lazy-object-proxy-1.9.0.tar.gz", hash = "sha256:659fb5809fa4629b8a1ac5106f669cfc7bef26fbb389dda53b3e010d1ac4ebae"},
+ {file = "lazy_object_proxy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b40387277b0ed2d0602b8293b94d7257e17d1479e257b4de114ea11a8cb7f2d7"},
+ {file = "lazy_object_proxy-1.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8c6cfb338b133fbdbc5cfaa10fe3c6aeea827db80c978dbd13bc9dd8526b7d4"},
+ {file = "lazy_object_proxy-1.9.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:721532711daa7db0d8b779b0bb0318fa87af1c10d7fe5e52ef30f8eff254d0cd"},
+ {file = "lazy_object_proxy-1.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:66a3de4a3ec06cd8af3f61b8e1ec67614fbb7c995d02fa224813cb7afefee701"},
+ {file = "lazy_object_proxy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1aa3de4088c89a1b69f8ec0dcc169aa725b0ff017899ac568fe44ddc1396df46"},
+ {file = "lazy_object_proxy-1.9.0-cp310-cp310-win32.whl", hash = "sha256:f0705c376533ed2a9e5e97aacdbfe04cecd71e0aa84c7c0595d02ef93b6e4455"},
+ {file = "lazy_object_proxy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea806fd4c37bf7e7ad82537b0757999264d5f70c45468447bb2b91afdbe73a6e"},
+ {file = "lazy_object_proxy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:946d27deaff6cf8452ed0dba83ba38839a87f4f7a9732e8f9fd4107b21e6ff07"},
+ {file = "lazy_object_proxy-1.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79a31b086e7e68b24b99b23d57723ef7e2c6d81ed21007b6281ebcd1688acb0a"},
+ {file = "lazy_object_proxy-1.9.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f699ac1c768270c9e384e4cbd268d6e67aebcfae6cd623b4d7c3bfde5a35db59"},
+ {file = "lazy_object_proxy-1.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfb38f9ffb53b942f2b5954e0f610f1e721ccebe9cce9025a38c8ccf4a5183a4"},
+ {file = "lazy_object_proxy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:189bbd5d41ae7a498397287c408617fe5c48633e7755287b21d741f7db2706a9"},
+ {file = "lazy_object_proxy-1.9.0-cp311-cp311-win32.whl", hash = "sha256:81fc4d08b062b535d95c9ea70dbe8a335c45c04029878e62d744bdced5141586"},
+ {file = "lazy_object_proxy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:f2457189d8257dd41ae9b434ba33298aec198e30adf2dcdaaa3a28b9994f6adb"},
+ {file = "lazy_object_proxy-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9e25ef10a39e8afe59a5c348a4dbf29b4868ab76269f81ce1674494e2565a6e"},
+ {file = "lazy_object_proxy-1.9.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbf9b082426036e19c6924a9ce90c740a9861e2bdc27a4834fd0a910742ac1e8"},
+ {file = "lazy_object_proxy-1.9.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f5fa4a61ce2438267163891961cfd5e32ec97a2c444e5b842d574251ade27d2"},
+ {file = "lazy_object_proxy-1.9.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8fa02eaab317b1e9e03f69aab1f91e120e7899b392c4fc19807a8278a07a97e8"},
+ {file = "lazy_object_proxy-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e7c21c95cae3c05c14aafffe2865bbd5e377cfc1348c4f7751d9dc9a48ca4bda"},
+ {file = "lazy_object_proxy-1.9.0-cp37-cp37m-win32.whl", hash = "sha256:f12ad7126ae0c98d601a7ee504c1122bcef553d1d5e0c3bfa77b16b3968d2734"},
+ {file = "lazy_object_proxy-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:edd20c5a55acb67c7ed471fa2b5fb66cb17f61430b7a6b9c3b4a1e40293b1671"},
+ {file = "lazy_object_proxy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0daa332786cf3bb49e10dc6a17a52f6a8f9601b4cf5c295a4f85854d61de63"},
+ {file = "lazy_object_proxy-1.9.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cd077f3d04a58e83d04b20e334f678c2b0ff9879b9375ed107d5d07ff160171"},
+ {file = "lazy_object_proxy-1.9.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:660c94ea760b3ce47d1855a30984c78327500493d396eac4dfd8bd82041b22be"},
+ {file = "lazy_object_proxy-1.9.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:212774e4dfa851e74d393a2370871e174d7ff0ebc980907723bb67d25c8a7c30"},
+ {file = "lazy_object_proxy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f0117049dd1d5635bbff65444496c90e0baa48ea405125c088e93d9cf4525b11"},
+ {file = "lazy_object_proxy-1.9.0-cp38-cp38-win32.whl", hash = "sha256:0a891e4e41b54fd5b8313b96399f8b0e173bbbfc03c7631f01efbe29bb0bcf82"},
+ {file = "lazy_object_proxy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:9990d8e71b9f6488e91ad25f322898c136b008d87bf852ff65391b004da5e17b"},
+ {file = "lazy_object_proxy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9e7551208b2aded9c1447453ee366f1c4070602b3d932ace044715d89666899b"},
+ {file = "lazy_object_proxy-1.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f83ac4d83ef0ab017683d715ed356e30dd48a93746309c8f3517e1287523ef4"},
+ {file = "lazy_object_proxy-1.9.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7322c3d6f1766d4ef1e51a465f47955f1e8123caee67dd641e67d539a534d006"},
+ {file = "lazy_object_proxy-1.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:18b78ec83edbbeb69efdc0e9c1cb41a3b1b1ed11ddd8ded602464c3fc6020494"},
+ {file = "lazy_object_proxy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:09763491ce220c0299688940f8dc2c5d05fd1f45af1e42e636b2e8b2303e4382"},
+ {file = "lazy_object_proxy-1.9.0-cp39-cp39-win32.whl", hash = "sha256:9090d8e53235aa280fc9239a86ae3ea8ac58eff66a705fa6aa2ec4968b95c821"},
+ {file = "lazy_object_proxy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:db1c1722726f47e10e0b5fdbf15ac3b8adb58c091d12b3ab713965795036985f"},
+]
+
+[[package]]
+name = "mccabe"
+version = "0.7.0"
+description = "McCabe checker, plugin for flake8"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
+ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
+]
+
+[[package]]
+name = "mypy"
+version = "1.7.1"
+description = "Optional static typing for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mypy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340"},
+ {file = "mypy-1.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49"},
+ {file = "mypy-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5"},
+ {file = "mypy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d"},
+ {file = "mypy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a"},
+ {file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"},
+ {file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"},
+ {file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"},
+ {file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"},
+ {file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"},
+ {file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"},
+ {file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"},
+ {file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"},
+ {file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"},
+ {file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"},
+ {file = "mypy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200"},
+ {file = "mypy-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7"},
+ {file = "mypy-1.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e"},
+ {file = "mypy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9"},
+ {file = "mypy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7"},
+ {file = "mypy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe"},
+ {file = "mypy-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce"},
+ {file = "mypy-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a"},
+ {file = "mypy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120"},
+ {file = "mypy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6"},
+ {file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"},
+ {file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"},
+]
+
+[package.dependencies]
+mypy-extensions = ">=1.0.0"
+typing-extensions = ">=4.1.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
+[[package]]
+name = "numpy"
+version = "1.26.2"
+description = "Fundamental package for array computing in Python"
+category = "main"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "numpy-1.26.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3703fc9258a4a122d17043e57b35e5ef1c5a5837c3db8be396c82e04c1cf9b0f"},
+ {file = "numpy-1.26.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc392fdcbd21d4be6ae1bb4475a03ce3b025cd49a9be5345d76d7585aea69440"},
+ {file = "numpy-1.26.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36340109af8da8805d8851ef1d74761b3b88e81a9bd80b290bbfed61bd2b4f75"},
+ {file = "numpy-1.26.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcc008217145b3d77abd3e4d5ef586e3bdfba8fe17940769f8aa09b99e856c00"},
+ {file = "numpy-1.26.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3ced40d4e9e18242f70dd02d739e44698df3dcb010d31f495ff00a31ef6014fe"},
+ {file = "numpy-1.26.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b272d4cecc32c9e19911891446b72e986157e6a1809b7b56518b4f3755267523"},
+ {file = "numpy-1.26.2-cp310-cp310-win32.whl", hash = "sha256:22f8fc02fdbc829e7a8c578dd8d2e15a9074b630d4da29cda483337e300e3ee9"},
+ {file = "numpy-1.26.2-cp310-cp310-win_amd64.whl", hash = "sha256:26c9d33f8e8b846d5a65dd068c14e04018d05533b348d9eaeef6c1bd787f9919"},
+ {file = "numpy-1.26.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b96e7b9c624ef3ae2ae0e04fa9b460f6b9f17ad8b4bec6d7756510f1f6c0c841"},
+ {file = "numpy-1.26.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aa18428111fb9a591d7a9cc1b48150097ba6a7e8299fb56bdf574df650e7d1f1"},
+ {file = "numpy-1.26.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06fa1ed84aa60ea6ef9f91ba57b5ed963c3729534e6e54055fc151fad0423f0a"},
+ {file = "numpy-1.26.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96ca5482c3dbdd051bcd1fce8034603d6ebfc125a7bd59f55b40d8f5d246832b"},
+ {file = "numpy-1.26.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:854ab91a2906ef29dc3925a064fcd365c7b4da743f84b123002f6139bcb3f8a7"},
+ {file = "numpy-1.26.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f43740ab089277d403aa07567be138fc2a89d4d9892d113b76153e0e412409f8"},
+ {file = "numpy-1.26.2-cp311-cp311-win32.whl", hash = "sha256:a2bbc29fcb1771cd7b7425f98b05307776a6baf43035d3b80c4b0f29e9545186"},
+ {file = "numpy-1.26.2-cp311-cp311-win_amd64.whl", hash = "sha256:2b3fca8a5b00184828d12b073af4d0fc5fdd94b1632c2477526f6bd7842d700d"},
+ {file = "numpy-1.26.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a4cd6ed4a339c21f1d1b0fdf13426cb3b284555c27ac2f156dfdaaa7e16bfab0"},
+ {file = "numpy-1.26.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d5244aabd6ed7f312268b9247be47343a654ebea52a60f002dc70c769048e75"},
+ {file = "numpy-1.26.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a3cdb4d9c70e6b8c0814239ead47da00934666f668426fc6e94cce869e13fd7"},
+ {file = "numpy-1.26.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa317b2325f7aa0a9471663e6093c210cb2ae9c0ad824732b307d2c51983d5b6"},
+ {file = "numpy-1.26.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:174a8880739c16c925799c018f3f55b8130c1f7c8e75ab0a6fa9d41cab092fd6"},
+ {file = "numpy-1.26.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f79b231bf5c16b1f39c7f4875e1ded36abee1591e98742b05d8a0fb55d8a3eec"},
+ {file = "numpy-1.26.2-cp312-cp312-win32.whl", hash = "sha256:4a06263321dfd3598cacb252f51e521a8cb4b6df471bb12a7ee5cbab20ea9167"},
+ {file = "numpy-1.26.2-cp312-cp312-win_amd64.whl", hash = "sha256:b04f5dc6b3efdaab541f7857351aac359e6ae3c126e2edb376929bd3b7f92d7e"},
+ {file = "numpy-1.26.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4eb8df4bf8d3d90d091e0146f6c28492b0be84da3e409ebef54349f71ed271ef"},
+ {file = "numpy-1.26.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1a13860fdcd95de7cf58bd6f8bc5a5ef81c0b0625eb2c9a783948847abbef2c2"},
+ {file = "numpy-1.26.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64308ebc366a8ed63fd0bf426b6a9468060962f1a4339ab1074c228fa6ade8e3"},
+ {file = "numpy-1.26.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baf8aab04a2c0e859da118f0b38617e5ee65d75b83795055fb66c0d5e9e9b818"},
+ {file = "numpy-1.26.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d73a3abcac238250091b11caef9ad12413dab01669511779bc9b29261dd50210"},
+ {file = "numpy-1.26.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b361d369fc7e5e1714cf827b731ca32bff8d411212fccd29ad98ad622449cc36"},
+ {file = "numpy-1.26.2-cp39-cp39-win32.whl", hash = "sha256:bd3f0091e845164a20bd5a326860c840fe2af79fa12e0469a12768a3ec578d80"},
+ {file = "numpy-1.26.2-cp39-cp39-win_amd64.whl", hash = "sha256:2beef57fb031dcc0dc8fa4fe297a742027b954949cabb52a2a376c144e5e6060"},
+ {file = "numpy-1.26.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1cc3d5029a30fb5f06704ad6b23b35e11309491c999838c31f124fee32107c79"},
+ {file = "numpy-1.26.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94cc3c222bb9fb5a12e334d0479b97bb2df446fbe622b470928f5284ffca3f8d"},
+ {file = "numpy-1.26.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe6b44fb8fcdf7eda4ef4461b97b3f63c466b27ab151bec2366db8b197387841"},
+ {file = "numpy-1.26.2.tar.gz", hash = "sha256:f65738447676ab5777f11e6bbbdb8ce11b785e105f690bc45966574816b6d3ea"},
+]
+
+[[package]]
+name = "packaging"
+version = "23.2"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
+ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
+]
+
+[[package]]
+name = "paho-mqtt"
+version = "1.6.1"
+description = "MQTT version 5.0/3.1.1 client class"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+ {file = "paho-mqtt-1.6.1.tar.gz", hash = "sha256:2a8291c81623aec00372b5a85558a372c747cbca8e9934dfe218638b8eefc26f"},
+]
+
+[package.extras]
+proxy = ["PySocks"]
+
+[[package]]
+name = "pep517"
+version = "0.13.1"
+description = "Wrappers to build Python packages using PEP 517 hooks"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "pep517-0.13.1-py3-none-any.whl", hash = "sha256:31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721"},
+ {file = "pep517-0.13.1.tar.gz", hash = "sha256:1b2fa2ffd3938bb4beffe5d6146cbcb2bda996a5a4da9f31abffd8b24e07b317"},
+]
+
+[[package]]
+name = "pip"
+version = "23.3.1"
+description = "The PyPA recommended tool for installing Python packages."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pip-23.3.1-py3-none-any.whl", hash = "sha256:55eb67bb6171d37447e82213be585b75fe2b12b359e993773aca4de9247a052b"},
+ {file = "pip-23.3.1.tar.gz", hash = "sha256:1fcaa041308d01f14575f6d0d2ea4b75a3e2871fe4f9c694976f908768e14174"},
+]
+
+[[package]]
+name = "pip-api"
+version = "0.0.30"
+description = "An unofficial, importable pip API"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pip-api-0.0.30.tar.gz", hash = "sha256:a05df2c7aa9b7157374bcf4273544201a0c7bae60a9c65bcf84f3959ef3896f3"},
+ {file = "pip_api-0.0.30-py3-none-any.whl", hash = "sha256:2a0314bd31522eb9ffe8a99668b0d07fee34ebc537931e7b6483001dbedcbdc9"},
+]
+
+[package.dependencies]
+pip = "*"
+
+[[package]]
+name = "pipreqs"
+version = "0.4.13"
+description = "Pip requirements.txt generator based on imports in project"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pipreqs-0.4.13-py2.py3-none-any.whl", hash = "sha256:e522b9ed54aa3e8b7978ff251ab7a9af2f75d2cd8de4c102e881b666a79a308e"},
+ {file = "pipreqs-0.4.13.tar.gz", hash = "sha256:a17f167880b6921be37533ce4c81ddc6e22b465c107aad557db43b1add56a99b"},
+]
+
+[package.dependencies]
+docopt = "*"
+yarg = "*"
+
+[[package]]
+name = "platformdirs"
+version = "4.1.0"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"},
+ {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"]
+
+[[package]]
+name = "plette"
+version = "0.4.4"
+description = "Structured Pipfile and Pipfile.lock models."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "plette-0.4.4-py2.py3-none-any.whl", hash = "sha256:42d68ce8c6b966874b68758d87d7f20fcff2eff0d861903eea1062126be4d98f"},
+ {file = "plette-0.4.4.tar.gz", hash = "sha256:06b8c09eb90293ad0b8101cb5c95c4ea53e9b2b582901845d0904ff02d237454"},
+]
+
+[package.dependencies]
+cerberus = {version = "*", optional = true, markers = "extra == \"validation\""}
+tomlkit = "*"
+
+[package.extras]
+tests = ["pytest", "pytest-cov", "pytest-xdist"]
+validation = ["cerberus"]
+
+[[package]]
+name = "pluggy"
+version = "1.3.0"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"},
+ {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pyaml"
+version = "21.10.1"
+description = "PyYAML-based module to produce pretty and readable YAML-serialized data"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+ {file = "pyaml-21.10.1-py2.py3-none-any.whl", hash = "sha256:19985ed303c3a985de4cf8fd329b6d0a5a5b5c9035ea240eccc709ebacbaf4a0"},
+ {file = "pyaml-21.10.1.tar.gz", hash = "sha256:c6519fee13bf06e3bb3f20cacdea8eba9140385a7c2546df5dbae4887f768383"},
+]
+
+[package.dependencies]
+PyYAML = "*"
+
+[[package]]
+name = "pycodestyle"
+version = "2.11.1"
+description = "Python style guide checker"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"},
+ {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"},
+]
+
+[[package]]
+name = "pycparser"
+version = "2.21"
+description = "C parser in Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
+]
+
+[[package]]
+name = "pydantic"
+version = "1.10.13"
+description = "Data validation and settings management using python type hints"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pydantic-1.10.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:efff03cc7a4f29d9009d1c96ceb1e7a70a65cfe86e89d34e4a5f2ab1e5693737"},
+ {file = "pydantic-1.10.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ecea2b9d80e5333303eeb77e180b90e95eea8f765d08c3d278cd56b00345d01"},
+ {file = "pydantic-1.10.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1740068fd8e2ef6eb27a20e5651df000978edce6da6803c2bef0bc74540f9548"},
+ {file = "pydantic-1.10.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84bafe2e60b5e78bc64a2941b4c071a4b7404c5c907f5f5a99b0139781e69ed8"},
+ {file = "pydantic-1.10.13-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bc0898c12f8e9c97f6cd44c0ed70d55749eaf783716896960b4ecce2edfd2d69"},
+ {file = "pydantic-1.10.13-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:654db58ae399fe6434e55325a2c3e959836bd17a6f6a0b6ca8107ea0571d2e17"},
+ {file = "pydantic-1.10.13-cp310-cp310-win_amd64.whl", hash = "sha256:75ac15385a3534d887a99c713aa3da88a30fbd6204a5cd0dc4dab3d770b9bd2f"},
+ {file = "pydantic-1.10.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c553f6a156deb868ba38a23cf0df886c63492e9257f60a79c0fd8e7173537653"},
+ {file = "pydantic-1.10.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e08865bc6464df8c7d61439ef4439829e3ab62ab1669cddea8dd00cd74b9ffe"},
+ {file = "pydantic-1.10.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e31647d85a2013d926ce60b84f9dd5300d44535a9941fe825dc349ae1f760df9"},
+ {file = "pydantic-1.10.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:210ce042e8f6f7c01168b2d84d4c9eb2b009fe7bf572c2266e235edf14bacd80"},
+ {file = "pydantic-1.10.13-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8ae5dd6b721459bfa30805f4c25880e0dd78fc5b5879f9f7a692196ddcb5a580"},
+ {file = "pydantic-1.10.13-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f8e81fc5fb17dae698f52bdd1c4f18b6ca674d7068242b2aff075f588301bbb0"},
+ {file = "pydantic-1.10.13-cp311-cp311-win_amd64.whl", hash = "sha256:61d9dce220447fb74f45e73d7ff3b530e25db30192ad8d425166d43c5deb6df0"},
+ {file = "pydantic-1.10.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4b03e42ec20286f052490423682016fd80fda830d8e4119f8ab13ec7464c0132"},
+ {file = "pydantic-1.10.13-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f59ef915cac80275245824e9d771ee939133be38215555e9dc90c6cb148aaeb5"},
+ {file = "pydantic-1.10.13-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a1f9f747851338933942db7af7b6ee8268568ef2ed86c4185c6ef4402e80ba8"},
+ {file = "pydantic-1.10.13-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:97cce3ae7341f7620a0ba5ef6cf043975cd9d2b81f3aa5f4ea37928269bc1b87"},
+ {file = "pydantic-1.10.13-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:854223752ba81e3abf663d685f105c64150873cc6f5d0c01d3e3220bcff7d36f"},
+ {file = "pydantic-1.10.13-cp37-cp37m-win_amd64.whl", hash = "sha256:b97c1fac8c49be29486df85968682b0afa77e1b809aff74b83081cc115e52f33"},
+ {file = "pydantic-1.10.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c958d053453a1c4b1c2062b05cd42d9d5c8eb67537b8d5a7e3c3032943ecd261"},
+ {file = "pydantic-1.10.13-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c5370a7edaac06daee3af1c8b1192e305bc102abcbf2a92374b5bc793818599"},
+ {file = "pydantic-1.10.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d6f6e7305244bddb4414ba7094ce910560c907bdfa3501e9db1a7fd7eaea127"},
+ {file = "pydantic-1.10.13-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3a3c792a58e1622667a2837512099eac62490cdfd63bd407993aaf200a4cf1f"},
+ {file = "pydantic-1.10.13-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c636925f38b8db208e09d344c7aa4f29a86bb9947495dd6b6d376ad10334fb78"},
+ {file = "pydantic-1.10.13-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:678bcf5591b63cc917100dc50ab6caebe597ac67e8c9ccb75e698f66038ea953"},
+ {file = "pydantic-1.10.13-cp38-cp38-win_amd64.whl", hash = "sha256:6cf25c1a65c27923a17b3da28a0bdb99f62ee04230c931d83e888012851f4e7f"},
+ {file = "pydantic-1.10.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8ef467901d7a41fa0ca6db9ae3ec0021e3f657ce2c208e98cd511f3161c762c6"},
+ {file = "pydantic-1.10.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:968ac42970f57b8344ee08837b62f6ee6f53c33f603547a55571c954a4225691"},
+ {file = "pydantic-1.10.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9849f031cf8a2f0a928fe885e5a04b08006d6d41876b8bbd2fc68a18f9f2e3fd"},
+ {file = "pydantic-1.10.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56e3ff861c3b9c6857579de282ce8baabf443f42ffba355bf070770ed63e11e1"},
+ {file = "pydantic-1.10.13-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f00790179497767aae6bcdc36355792c79e7bbb20b145ff449700eb076c5f96"},
+ {file = "pydantic-1.10.13-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:75b297827b59bc229cac1a23a2f7a4ac0031068e5be0ce385be1462e7e17a35d"},
+ {file = "pydantic-1.10.13-cp39-cp39-win_amd64.whl", hash = "sha256:e70ca129d2053fb8b728ee7d1af8e553a928d7e301a311094b8a0501adc8763d"},
+ {file = "pydantic-1.10.13-py3-none-any.whl", hash = "sha256:b87326822e71bd5f313e7d3bfdc77ac3247035ac10b0c0618bd99dcf95b1e687"},
+ {file = "pydantic-1.10.13.tar.gz", hash = "sha256:32c8b48dcd3b2ac4e78b0ba4af3a2c2eb6048cb75202f0ea7b34feb740efc340"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.2.0"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
+
+[[package]]
+name = "pylint"
+version = "2.17.7"
+description = "python code static checker"
+category = "dev"
+optional = false
+python-versions = ">=3.7.2"
+files = [
+ {file = "pylint-2.17.7-py3-none-any.whl", hash = "sha256:27a8d4c7ddc8c2f8c18aa0050148f89ffc09838142193fdbe98f172781a3ff87"},
+ {file = "pylint-2.17.7.tar.gz", hash = "sha256:f4fcac7ae74cfe36bc8451e931d8438e4a476c20314b1101c458ad0f05191fad"},
+]
+
+[package.dependencies]
+astroid = ">=2.15.8,<=2.17.0-dev0"
+colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
+dill = {version = ">=0.3.6", markers = "python_version >= \"3.11\""}
+isort = ">=4.2.5,<6"
+mccabe = ">=0.6,<0.8"
+platformdirs = ">=2.2.0"
+tomlkit = ">=0.10.1"
+
+[package.extras]
+spelling = ["pyenchant (>=3.2,<4.0)"]
+testutils = ["gitpython (>3)"]
+
+[[package]]
+name = "pyobjc-core"
+version = "9.2"
+description = "Python<->ObjC Interoperability Module"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pyobjc-core-9.2.tar.gz", hash = "sha256:d734b9291fec91ff4e3ae38b9c6839debf02b79c07314476e87da8e90b2c68c3"},
+ {file = "pyobjc_core-9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fa674a39949f5cde8e5c7bbcd24496446bfc67592b028aedbec7f81dc5fc4daa"},
+ {file = "pyobjc_core-9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bbc8de304ee322a1ee530b4d2daca135a49b4a49aa3cedc6b2c26c43885f4842"},
+ {file = "pyobjc_core-9.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0fa950f092673883b8bd28bc18397415cabb457bf410920762109b411789ade9"},
+ {file = "pyobjc_core-9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:586e4cae966282eaa61b21cae66ccdcee9d69c036979def26eebdc08ddebe20f"},
+ {file = "pyobjc_core-9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:41189c2c680931c0395a55691763c481fc681f454f21bb4f1644f98c24a45954"},
+ {file = "pyobjc_core-9.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:2d23ee539f2ba5e9f5653d75a13f575c7e36586fc0086792739e69e4c2617eda"},
+ {file = "pyobjc_core-9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b9809cf96678797acb72a758f34932fe8e2602d5ab7abec15c5ac68ddb481720"},
+]
+
+[[package]]
+name = "pyobjc-framework-cocoa"
+version = "9.2"
+description = "Wrappers for the Cocoa frameworks on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pyobjc-framework-Cocoa-9.2.tar.gz", hash = "sha256:efd78080872d8c8de6c2b97e0e4eac99d6203a5d1637aa135d071d464eb2db53"},
+ {file = "pyobjc_framework_Cocoa-9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9e02d8a7cc4eb7685377c50ba4f17345701acf4c05b1e7480d421bff9e2f62a4"},
+ {file = "pyobjc_framework_Cocoa-9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3b1e6287b3149e4c6679cdbccd8e9ef6557a4e492a892e80a77df143f40026d2"},
+ {file = "pyobjc_framework_Cocoa-9.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:312977ce2e3989073c6b324c69ba24283de206fe7acd6dbbbaf3e29238a22537"},
+ {file = "pyobjc_framework_Cocoa-9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:aae7841cf40c26dd915f4dd828f91c6616e6b7998630b72e704750c09e00f334"},
+ {file = "pyobjc_framework_Cocoa-9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:739a421e14382a46cbeb9a883f192dceff368ad28ec34d895c48c0ad34cf2c1d"},
+ {file = "pyobjc_framework_Cocoa-9.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:32d9ac1033fac1b821ddee8c68f972a7074ad8c50bec0bea9a719034c1c2fb94"},
+ {file = "pyobjc_framework_Cocoa-9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b236bb965e41aeb2e215d4e98a5a230d4b63252c6d26e00924ea2e69540a59d6"},
+]
+
+[package.dependencies]
+pyobjc-core = ">=9.2"
+
+[[package]]
+name = "pyobjc-framework-corebluetooth"
+version = "9.2"
+description = "Wrappers for the framework CoreBluetooth on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pyobjc-framework-CoreBluetooth-9.2.tar.gz", hash = "sha256:cb2481b1dfe211ae9ce55f36537dc8155dbf0dc8ff26e0bc2e13f7afb0a291d1"},
+ {file = "pyobjc_framework_CoreBluetooth-9.2-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:53d888742119d0f0c725d0b0c2389f68e8f21f0cba6d6aec288c53260a0196b6"},
+ {file = "pyobjc_framework_CoreBluetooth-9.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:179532882126526e38fe716a50fb0ee8f440e0b838d290252c515e622b5d0e49"},
+ {file = "pyobjc_framework_CoreBluetooth-9.2-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:256a5031ea9d8a7406541fa1b0dfac549b1de93deae8284605f9355b13fb58be"},
+]
+
+[package.dependencies]
+pyobjc-core = ">=9.2"
+pyobjc-framework-Cocoa = ">=9.2"
+
+[[package]]
+name = "pyobjc-framework-libdispatch"
+version = "9.2"
+description = "Wrappers for libdispatch on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pyobjc-framework-libdispatch-9.2.tar.gz", hash = "sha256:542e7f7c2b041939db5ed6f3119c1d67d73ec14a996278b92485f8513039c168"},
+ {file = "pyobjc_framework_libdispatch-9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88d4091d4bcb5702783d6e86b4107db973425a17d1de491543f56bd348909b60"},
+ {file = "pyobjc_framework_libdispatch-9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1a67b007113328538b57893cc7829a722270764cdbeae6d5e1460a1d911314df"},
+ {file = "pyobjc_framework_libdispatch-9.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6fccea1a57436cf1ac50d9ebc6e3e725bcf77f829ba6b118e62e6ed7866d359d"},
+ {file = "pyobjc_framework_libdispatch-9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6eba747b7ad91b0463265a7aee59235bb051fb97687f35ca2233690369b5e4e4"},
+ {file = "pyobjc_framework_libdispatch-9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2e835495860d04f63c2d2f73ae3dd79da4222864c107096dc0f99e8382700026"},
+ {file = "pyobjc_framework_libdispatch-9.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1b107e5c3580b09553030961ea6b17abad4a5132101eab1af3ad2cb36d0f08bb"},
+ {file = "pyobjc_framework_libdispatch-9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:83cdb672acf722717b5ecf004768f215f02ac02d7f7f2a9703da6e921ab02222"},
+]
+
+[package.dependencies]
+pyobjc-core = ">=9.2"
+
+[[package]]
+name = "pytest"
+version = "7.4.3"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"},
+ {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+
+[package.extras]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-asyncio"
+version = "0.23.2"
+description = "Pytest support for asyncio"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-asyncio-0.23.2.tar.gz", hash = "sha256:c16052382554c7b22d48782ab3438d5b10f8cf7a4bdcae7f0f67f097d95beecc"},
+ {file = "pytest_asyncio-0.23.2-py3-none-any.whl", hash = "sha256:ea9021364e32d58f0be43b91c6233fb8d2224ccef2398d6837559e587682808f"},
+]
+
+[package.dependencies]
+pytest = ">=7.0.0"
+
+[package.extras]
+docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
+testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
+
+[[package]]
+name = "pytest-mock"
+version = "3.12.0"
+description = "Thin-wrapper around the mock package for easier use with pytest"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"},
+ {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"},
+]
+
+[package.dependencies]
+pytest = ">=5.0"
+
+[package.extras]
+dev = ["pre-commit", "pytest-asyncio", "tox"]
+
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+ {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "pytz"
+version = "2023.3.post1"
+description = "World timezone definitions, modern and historical"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+ {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"},
+ {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"},
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.1"
+description = "YAML parser and emitter for Python"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+ {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+ {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+ {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
+ {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+ {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
+ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
+]
+
+[[package]]
+name = "requests"
+version = "2.31.0"
+description = "Python HTTP for Humans."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
+ {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "requirementslib"
+version = "3.0.0"
+description = "A tool for converting between pip-style and pipfile requirements."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "requirementslib-3.0.0-py2.py3-none-any.whl", hash = "sha256:67b42903d7c32f89c7047d1020c619d37cb515c475a4ae6f4e5683e1c56d7bf7"},
+ {file = "requirementslib-3.0.0.tar.gz", hash = "sha256:28f8e0b1c38b34ae06de68ef115b03bbcdcdb99f9e9393333ff06ded443e3f24"},
+]
+
+[package.dependencies]
+distlib = ">=0.2.8"
+pep517 = ">=0.5.0"
+pip = ">=23.1"
+platformdirs = "*"
+plette = {version = "*", extras = ["validation"]}
+pydantic = "*"
+requests = "*"
+setuptools = ">=40.8"
+tomlkit = ">=0.5.3"
+
+[package.extras]
+dev = ["nox", "parver", "towncrier", "twine"]
+docs = ["sphinx", "sphinx-rtd-theme"]
+tests = ["coverage", "hypothesis", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "readme-renderer[md]"]
+
+[[package]]
+name = "setuptools"
+version = "69.0.2"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"},
+ {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "thelogrus"
+version = "0.7.0"
+description = "The Logrus is a collection of random utility functions"
+category = "main"
+optional = false
+python-versions = ">=3.6,<4.0"
+files = [
+ {file = "thelogrus-0.7.0-py3-none-any.whl", hash = "sha256:d6b3e16e9a6c7ef8d9b9e5823434aab2720d5d7bb83450f69aaa589b9c68eda3"},
+ {file = "thelogrus-0.7.0.tar.gz", hash = "sha256:fd7171e065bb739ab54ac3c5098e601037ca7ca91366b8e96140e078b1fe4e04"},
+]
+
+[package.dependencies]
+dateutils = ">=0.6.12,<0.7.0"
+pyaml = ">=21.10.1,<22.0.0"
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.12.3"
+description = "Style preserving TOML library"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"},
+ {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.9.0"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+category = "main"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"},
+ {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"},
+]
+
+[[package]]
+name = "urllib3"
+version = "2.1.0"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+category = "main"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"},
+ {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "winrt-runtime"
+version = "2.0.0b1"
+description = "Python projection of Windows Runtime (WinRT) APIs"
+category = "main"
+optional = false
+python-versions = "<3.13,>=3.9"
+files = [
+ {file = "winrt-runtime-2.0.0b1.tar.gz", hash = "sha256:28db2ebe7bfb347d110224e9f23fe8079cea45af0fcbd643d039524ced07d22c"},
+ {file = "winrt_runtime-2.0.0b1-cp310-cp310-win32.whl", hash = "sha256:8f812b01e2c8dd3ca68aa51a7aa02e815cc2ac3c8520a883b4ec7a4fc63afb04"},
+ {file = "winrt_runtime-2.0.0b1-cp310-cp310-win_amd64.whl", hash = "sha256:f36f6102f9b7a08d917a6809117c085639b66be2c579f4089d3fd47b83e8f87b"},
+ {file = "winrt_runtime-2.0.0b1-cp310-cp310-win_arm64.whl", hash = "sha256:4a99f267da96edc977623355b816b46c1344c66dc34732857084417d8cf9a96b"},
+ {file = "winrt_runtime-2.0.0b1-cp311-cp311-win32.whl", hash = "sha256:ba998e3fc452338c5e2d7bf5174a6206580245066d60079ee4130082d0eb61c2"},
+ {file = "winrt_runtime-2.0.0b1-cp311-cp311-win_amd64.whl", hash = "sha256:e7838f0fdf5653ce245888590214177a1f54884cece2c8dfbfe3d01b2780171e"},
+ {file = "winrt_runtime-2.0.0b1-cp311-cp311-win_arm64.whl", hash = "sha256:2afa45b7385e99a63d55ccda29096e6a84fcd4c654479005c147b0e65e274abf"},
+ {file = "winrt_runtime-2.0.0b1-cp312-cp312-win32.whl", hash = "sha256:edda124ff965cec3a6bfdb26fbe88e004f96975dd84115176e30c1efbcb16f4c"},
+ {file = "winrt_runtime-2.0.0b1-cp312-cp312-win_amd64.whl", hash = "sha256:d8935951efeec6b3d546dce8f48bb203aface57a1ba991c066f0e12e84c8f91e"},
+ {file = "winrt_runtime-2.0.0b1-cp312-cp312-win_arm64.whl", hash = "sha256:509fb9a03af5e1125433f58522725716ceef040050d33625460b5a5eb98a46ac"},
+ {file = "winrt_runtime-2.0.0b1-cp39-cp39-win32.whl", hash = "sha256:41138fe4642345d7143e817ce0905d82e60b3832558143e0a17bfea8654c6512"},
+ {file = "winrt_runtime-2.0.0b1-cp39-cp39-win_amd64.whl", hash = "sha256:081a429fe85c33cb6610c4a799184b7650b30f15ab1d89866f2bda246d3a5c0a"},
+ {file = "winrt_runtime-2.0.0b1-cp39-cp39-win_arm64.whl", hash = "sha256:e6984604c6ae1f3258973ba2503d1ea5aa15e536ca41d6a131ad305ebbb6519d"},
+]
+
+[[package]]
+name = "winrt-windows-devices-bluetooth"
+version = "2.0.0b1"
+description = "Python projection of Windows Runtime (WinRT) APIs"
+category = "main"
+optional = false
+python-versions = "<3.13,>=3.9"
+files = [
+ {file = "winrt-Windows.Devices.Bluetooth-2.0.0b1.tar.gz", hash = "sha256:786bd43786b873a083b89debece538974f720584662a2573d6a8a8501a532860"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp310-cp310-win32.whl", hash = "sha256:79631bf3f96954da260859df9228a028835ffade0d885ba3942c5a86a853d150"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp310-cp310-win_amd64.whl", hash = "sha256:cd85337a95065d0d2045c06db1a5edd4a447aad47cf7027818f6fb69f831c56c"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp310-cp310-win_arm64.whl", hash = "sha256:6a963869ed003d260e90e9bedc334129303f263f068ea1c0d994df53317db2bc"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp311-cp311-win32.whl", hash = "sha256:7c5951943a3911d94a8da190f4355dc70128d7d7f696209316372c834b34d462"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp311-cp311-win_amd64.whl", hash = "sha256:b0bb154ae92235649ed234982f609c490a467d5049c27d63397be9abbb00730e"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp311-cp311-win_arm64.whl", hash = "sha256:6688dfb0fc3b7dc517bf8cf40ae00544a50b4dec91470d37be38fc33c4523632"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp312-cp312-win32.whl", hash = "sha256:613c6ff4125df46189b3bef6d3110d94ec725d357ab734f00eedb11c4116c367"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp312-cp312-win_amd64.whl", hash = "sha256:59c403b64e9f4e417599c6f6aea6ee6fac960597c21eac6b3fd8a84f64aa387c"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp312-cp312-win_arm64.whl", hash = "sha256:b7f6e1b9bb6e33be80045adebd252cf25cd648759fad6e86c61a393ddd709f7f"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp39-cp39-win32.whl", hash = "sha256:eae7a89106eab047e96843e28c3c6ce0886dd7dee60180a1010498925e9503f9"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp39-cp39-win_amd64.whl", hash = "sha256:8dfd1915c894ac19dd0b24aba38ef676c92c3473c0d9826762ba9616ad7df68b"},
+ {file = "winrt_Windows.Devices.Bluetooth-2.0.0b1-cp39-cp39-win_arm64.whl", hash = "sha256:49058587e6d82ba33da0767b97a378ddfea8e3a5991bdeff680faa287bfae57e"},
+]
+
+[package.dependencies]
+winrt-runtime = "2.0.0-beta.1"
+
+[package.extras]
+all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (==2.0.0-beta.1)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (==2.0.0-beta.1)", "winrt-Windows.Devices.Enumeration[all] (==2.0.0-beta.1)", "winrt-Windows.Devices.Radios[all] (==2.0.0-beta.1)", "winrt-Windows.Foundation.Collections[all] (==2.0.0-beta.1)", "winrt-Windows.Foundation[all] (==2.0.0-beta.1)", "winrt-Windows.Networking[all] (==2.0.0-beta.1)", "winrt-Windows.Storage.Streams[all] (==2.0.0-beta.1)"]
+
+[[package]]
+name = "winrt-windows-devices-bluetooth-advertisement"
+version = "2.0.0b1"
+description = "Python projection of Windows Runtime (WinRT) APIs"
+category = "main"
+optional = false
+python-versions = "<3.13,>=3.9"
+files = [
+ {file = "winrt-Windows.Devices.Bluetooth.Advertisement-2.0.0b1.tar.gz", hash = "sha256:d9050faa4377d410d4f0e9cabb5ec555a267531c9747370555ac9ec93ec9f399"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp310-cp310-win32.whl", hash = "sha256:ac9b703d16adc87c3541585525b8fcf6d84391e2fa010c2f001e714c405cc3b7"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp310-cp310-win_amd64.whl", hash = "sha256:593cade7853a8b0770e8ef30462b5d5f477b82e17e0aa590094b1c26efd3e05a"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp310-cp310-win_arm64.whl", hash = "sha256:574698c08895e2cfee7379bdf34a5f319fe440d7dfcc7bc9858f457c08e9712c"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp311-cp311-win32.whl", hash = "sha256:652a096f8210036bbb539d7f971eaf1f472a3aeb60b7e31278e3d0d30a355292"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp311-cp311-win_amd64.whl", hash = "sha256:e5cfb866c44dad644fb44b441f4fdbddafc9564075f1f68f756e20f438105c67"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp311-cp311-win_arm64.whl", hash = "sha256:6c2503eaaf5cd988b5510b86347dba45ad6ee52656f9656a1a97abae6d35386e"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp312-cp312-win32.whl", hash = "sha256:780c766725a55f4211f921c773c92c2331803e70f65d6ad6676a60f903d39a54"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp312-cp312-win_amd64.whl", hash = "sha256:39c8633d01039eb2c2f6f20cfc43c045a333b9f3a45229e2ce443f71bb2a562c"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp312-cp312-win_arm64.whl", hash = "sha256:eaa0d44b4158b16937eac8102249e792f0299dbb0aefc56cc9adc9552e8f9afe"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp39-cp39-win32.whl", hash = "sha256:d171487e23f7671ad2923544bfa6545d0a29a1a9ae1f5c1d5e5e5f473a5d62b2"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp39-cp39-win_amd64.whl", hash = "sha256:442eecac87653a03617e65bdb2ef79ddc0582dfdacc2be8af841fba541577f8b"},
+ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.0b1-cp39-cp39-win_arm64.whl", hash = "sha256:b30ab9b8c1ecf818be08bac86bee425ef40f75060c4011d4e6c2e624a7b9916e"},
+]
+
+[package.dependencies]
+winrt-runtime = "2.0.0-beta.1"
+
+[package.extras]
+all = ["winrt-Windows.Devices.Bluetooth[all] (==2.0.0-beta.1)", "winrt-Windows.Foundation.Collections[all] (==2.0.0-beta.1)", "winrt-Windows.Foundation[all] (==2.0.0-beta.1)", "winrt-Windows.Storage.Streams[all] (==2.0.0-beta.1)"]
+
+[[package]]
+name = "winrt-windows-devices-bluetooth-genericattributeprofile"
+version = "2.0.0b1"
+description = "Python projection of Windows Runtime (WinRT) APIs"
+category = "main"
+optional = false
+python-versions = "<3.13,>=3.9"
+files = [
+ {file = "winrt-Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1.tar.gz", hash = "sha256:93b745d51ecfb3e9d3a21623165cc065735c9e0146cb7a26744182c164e63e14"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp310-cp310-win32.whl", hash = "sha256:db740aaedd80cca5b1a390663b26c7733eb08f4c57ade6a04b055d548e9d042b"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp310-cp310-win_amd64.whl", hash = "sha256:7c81aa6c066cdab58bcc539731f208960e094a6d48b59118898e1e804dbbdf7f"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp310-cp310-win_arm64.whl", hash = "sha256:92277a6bbcbe2225ad1be92968af597dc77bc37a63cd729690d2d9fb5094ae25"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp311-cp311-win32.whl", hash = "sha256:6b48209669c1e214165530793cf9916ae44a0ae2618a9be7a489e8c94f7e745f"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp311-cp311-win_amd64.whl", hash = "sha256:2f17216e6ce748eaef02fb0658213515d3ff31e2dbb18f070a614876f818c90d"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp311-cp311-win_arm64.whl", hash = "sha256:db798a0f0762e390da5a9f02f822daff00692bd951a492224bf46782713b2938"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp312-cp312-win32.whl", hash = "sha256:b8d9dba04b9cfa53971c35117fc3c68c94bfa5e2ed18ce680f731743598bf246"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp312-cp312-win_amd64.whl", hash = "sha256:e5260b3f33dee8a896604297e05efc04d04298329c205a74ded8e2d6333e84b7"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp312-cp312-win_arm64.whl", hash = "sha256:822ef539389ecb546004345c4dce8b9b7788e2e99a1d6f0947a4b123dceb7fed"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp39-cp39-win32.whl", hash = "sha256:11e6863e7a94d2b6dd76ddcd19c01e311895810a4ce6ad08c7b5534294753243"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp39-cp39-win_amd64.whl", hash = "sha256:20de8d04c301c406362c93e78d41912aea0af23c4b430704aba329420d7c2cdf"},
+ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.0b1-cp39-cp39-win_arm64.whl", hash = "sha256:918059796f2f123216163b928ecde8ecec17994fb7a94042af07fda82c132a6d"},
+]
+
+[package.dependencies]
+winrt-runtime = "2.0.0-beta.1"
+
+[package.extras]
+all = ["winrt-Windows.Devices.Bluetooth[all] (==2.0.0-beta.1)", "winrt-Windows.Devices.Enumeration[all] (==2.0.0-beta.1)", "winrt-Windows.Foundation.Collections[all] (==2.0.0-beta.1)", "winrt-Windows.Foundation[all] (==2.0.0-beta.1)", "winrt-Windows.Storage.Streams[all] (==2.0.0-beta.1)"]
+
+[[package]]
+name = "winrt-windows-devices-enumeration"
+version = "2.0.0b1"
+description = "Python projection of Windows Runtime (WinRT) APIs"
+category = "main"
+optional = false
+python-versions = "<3.13,>=3.9"
+files = [
+ {file = "winrt-Windows.Devices.Enumeration-2.0.0b1.tar.gz", hash = "sha256:8f214040e4edbe57c4943488887db89f4a00d028c34169aafd2205e228026100"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp310-cp310-win32.whl", hash = "sha256:dcb9e7d230aefec8531a46d393ecb1063b9d4b97c9f3ff2fc537ce22bdfa2444"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp310-cp310-win_amd64.whl", hash = "sha256:22a3e1fef40786cc8d51320b6f11ff25de6c674475f3ba608a46915e1dadf0f5"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp310-cp310-win_arm64.whl", hash = "sha256:2edcfeb70a71d40622873cad96982a28e92a7ee71f33968212dd3598b2d8d469"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp311-cp311-win32.whl", hash = "sha256:ce4eb88add7f5946d2666761a97a3bb04cac2a061d264f03229c1e15dbd7ce91"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp311-cp311-win_amd64.whl", hash = "sha256:a9001f17991572abdddab7ab074e08046e74e05eeeaf3b2b01b8b47d2879b64c"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp311-cp311-win_arm64.whl", hash = "sha256:0440b91ce144111e207f084cec6b1277162ef2df452d321951e989ce87dc9ced"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp312-cp312-win32.whl", hash = "sha256:e4fae13126f13a8d9420b74fb5a5ff6a6b2f91f7718c4be2d4a8dc1337c58f59"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp312-cp312-win_amd64.whl", hash = "sha256:e352eebc23dc94fb79e67a056c057fb0e16c20c8cb881dc826094c20ed4791e3"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp312-cp312-win_arm64.whl", hash = "sha256:b43f5c1f053a170e6e4b44ba69838ac223f9051adca1a56506d4c46e98d1485f"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp39-cp39-win32.whl", hash = "sha256:ed245fad8de6a134d5c3a630204e7f8238aa944a40388005bce0ce3718c410fa"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp39-cp39-win_amd64.whl", hash = "sha256:22a9eefdbfe520778512266d0b48ff239eaa8d272fce6f5cb1ff352bed0619f4"},
+ {file = "winrt_Windows.Devices.Enumeration-2.0.0b1-cp39-cp39-win_arm64.whl", hash = "sha256:397d43f8fd2621a7719b9eab6a4a8e72a1d6fa2d9c36525a30812f8e7bad3bdf"},
+]
+
+[package.dependencies]
+winrt-runtime = "2.0.0-beta.1"
+
+[package.extras]
+all = ["winrt-Windows.ApplicationModel.Background[all] (==2.0.0-beta.1)", "winrt-Windows.Foundation.Collections[all] (==2.0.0-beta.1)", "winrt-Windows.Foundation[all] (==2.0.0-beta.1)", "winrt-Windows.Security.Credentials[all] (==2.0.0-beta.1)", "winrt-Windows.Storage.Streams[all] (==2.0.0-beta.1)", "winrt-Windows.UI.Popups[all] (==2.0.0-beta.1)", "winrt-Windows.UI[all] (==2.0.0-beta.1)"]
+
+[[package]]
+name = "winrt-windows-foundation"
+version = "2.0.0b1"
+description = "Python projection of Windows Runtime (WinRT) APIs"
+category = "main"
+optional = false
+python-versions = "<3.13,>=3.9"
+files = [
+ {file = "winrt-Windows.Foundation-2.0.0b1.tar.gz", hash = "sha256:976b6da942747a7ca5a179a35729d8dc163f833e03b085cf940332a5e9070d54"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp310-cp310-win32.whl", hash = "sha256:5337ac1ec260132fbff868603e73a3738d4001911226e72669b3d69c8a256d5e"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp310-cp310-win_amd64.whl", hash = "sha256:af969e5bb9e2e41e4e86a361802528eafb5eb8fe87ec1dba6048c0702d63caa8"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp310-cp310-win_arm64.whl", hash = "sha256:bbbfa6b3c444a1074a630fd4a1b71171be7a5c9bb07c827ad9259fadaed56cf2"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp311-cp311-win32.whl", hash = "sha256:b91bd92b1854c073acd81aa87cf8df571d2151b1dd050b6181aa36f7acc43df4"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp311-cp311-win_amd64.whl", hash = "sha256:2f5359f25703347e827dbac982150354069030f1deecd616f7ce37ad90cbcb00"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp311-cp311-win_arm64.whl", hash = "sha256:0f1f1978173ddf0ee6262c2edb458f62d628b9fa0df10cd1e8c78c833af3197e"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp312-cp312-win32.whl", hash = "sha256:c1d23b737f733104b91c89c507b58d0b3ef5f3234a1b608ef6dfb6dbbb8777ea"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp312-cp312-win_amd64.whl", hash = "sha256:95de6c29e9083fe63f127b965b54dfa52a6424a93a94ce87cfad4c1900a6e887"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp312-cp312-win_arm64.whl", hash = "sha256:4707063a5a6980e3f71aebeea5ac93101c753ec13a0b47be9ea4dbc0d5ff361e"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp39-cp39-win32.whl", hash = "sha256:d0259f1f4a1b8e20d0cbd935a889c0f7234f720645590260f9cf3850fdc1e1fa"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp39-cp39-win_amd64.whl", hash = "sha256:15c7b324d0f59839fb4492d84bb1c870881c5c67cb94ac24c664a7c4dce1c475"},
+ {file = "winrt_Windows.Foundation-2.0.0b1-cp39-cp39-win_arm64.whl", hash = "sha256:16ad741f4d38e99f8409ba5760299d0052003255f970f49f4b8ba2e0b609c8b7"},
+]
+
+[package.dependencies]
+winrt-runtime = "2.0.0-beta.1"
+
+[package.extras]
+all = ["winrt-Windows.Foundation.Collections[all] (==2.0.0-beta.1)"]
+
+[[package]]
+name = "winrt-windows-foundation-collections"
+version = "2.0.0b1"
+description = "Python projection of Windows Runtime (WinRT) APIs"
+category = "main"
+optional = false
+python-versions = "<3.13,>=3.9"
+files = [
+ {file = "winrt-Windows.Foundation.Collections-2.0.0b1.tar.gz", hash = "sha256:185d30f8103934124544a40aac005fa5918a9a7cb3179f45e9863bb86e22ad43"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp310-cp310-win32.whl", hash = "sha256:042142e916a170778b7154498aae61254a1a94c552954266b73479479d24f01d"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp310-cp310-win_amd64.whl", hash = "sha256:9f68e66055121fc1e04c4fda627834aceee6fbe922e77d6ccaecf9582e714c57"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp310-cp310-win_arm64.whl", hash = "sha256:a4609411263cc7f5e93a9a5677b21e2ef130e26f9030bfa960b3e82595324298"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp311-cp311-win32.whl", hash = "sha256:5296858aa44c53936460a119794b80eedd6bd094016c1bf96822f92cb95ea419"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp311-cp311-win_amd64.whl", hash = "sha256:3db1e1c80c97474e7c88b6052bd8982ca61723fd58ace11dc91a5522662e0b2a"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp311-cp311-win_arm64.whl", hash = "sha256:c3a594e660c59f9fab04ae2f40bda7c809e8ec4748bada4424dfb02b43d4bfe1"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp312-cp312-win32.whl", hash = "sha256:0f355ee943ec5b835e694d97e9e93545a42d6fb984a61f442467789550d62c3f"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp312-cp312-win_amd64.whl", hash = "sha256:c4a0cd2eb9f47c7ca3b66d12341cc822250bf26854a93fd58ab77f7a48dfab3a"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp312-cp312-win_arm64.whl", hash = "sha256:744dbef50e8b8f34904083cae9ad43ac6e28facb9e166c4f123ce8e758141067"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp39-cp39-win32.whl", hash = "sha256:b7c767184aec3a3d7cba2cd84fadcd68106854efabef1a61092052294d6d6f4f"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp39-cp39-win_amd64.whl", hash = "sha256:7c1ffe99c12f14fc4ab7027757780e6d850fa2fb23ec404a54311fbd9f1970d3"},
+ {file = "winrt_Windows.Foundation.Collections-2.0.0b1-cp39-cp39-win_arm64.whl", hash = "sha256:870fa040ed36066e4c240c35973d8b2e0d7c38cc6050a42d993715ec9e3b748c"},
+]
+
+[package.dependencies]
+winrt-runtime = "2.0.0-beta.1"
+
+[package.extras]
+all = ["winrt-Windows.Foundation[all] (==2.0.0-beta.1)"]
+
+[[package]]
+name = "winrt-windows-storage-streams"
+version = "2.0.0b1"
+description = "Python projection of Windows Runtime (WinRT) APIs"
+category = "main"
+optional = false
+python-versions = "<3.13,>=3.9"
+files = [
+ {file = "winrt-Windows.Storage.Streams-2.0.0b1.tar.gz", hash = "sha256:029d67cdc9b092d56c682740fe3c42f267dc5d3346b5c0b12ebc03f38e7d2f1f"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp310-cp310-win32.whl", hash = "sha256:49c90d4bfd539f6676226dfcb4b3574ddd6be528ffc44aa214c55af88c2de89e"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp310-cp310-win_amd64.whl", hash = "sha256:22cc82779cada84aa2633841e25b33f3357737d912a1d9ecc1ee5a8b799b5171"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp310-cp310-win_arm64.whl", hash = "sha256:b1750a111be32466f4f0781cbb5df195ac940690571dff4564492b921b162563"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp311-cp311-win32.whl", hash = "sha256:e79b1183ab26d9b95cf3e6dbe3f488a40605174a5a112694dbb7dbfb50899daf"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp311-cp311-win_amd64.whl", hash = "sha256:3e90a1207eb3076f051a7785132f7b056b37343a68e9481a50c6defb3f660099"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp311-cp311-win_arm64.whl", hash = "sha256:4da06522b4fa9cfcc046b604cc4aa1c6a887cc4bb5b8a637ed9bff8028a860bb"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp312-cp312-win32.whl", hash = "sha256:6f74f8ab8ac0d8de61c709043315361d8ac63f8144f3098d428472baadf8246a"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp312-cp312-win_amd64.whl", hash = "sha256:5cf7c8d67836c60392d167bfe4f98ac7abcb691bfba2d19e322d0f9181f58347"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp312-cp312-win_arm64.whl", hash = "sha256:f7f679f2c0f71791eca835856f57942ee5245094c1840a6c34bc7c2176b1bcd6"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp39-cp39-win32.whl", hash = "sha256:5beb53429fa9a11ede56b4a7cefe28c774b352dd355f7951f2a4dd7e9ec9b39a"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp39-cp39-win_amd64.whl", hash = "sha256:f84233c4b500279d8f5840cb8c47776bc040fcecba05c6c9ab9767053698fc8b"},
+ {file = "winrt_Windows.Storage.Streams-2.0.0b1-cp39-cp39-win_arm64.whl", hash = "sha256:cfb163ddbb435906f75ef92a768573b0190e194e1438cea5a4c1d4d32a6b9386"},
+]
+
+[package.dependencies]
+winrt-runtime = "2.0.0-beta.1"
+
+[package.extras]
+all = ["winrt-Windows.Foundation.Collections[all] (==2.0.0-beta.1)", "winrt-Windows.Foundation[all] (==2.0.0-beta.1)", "winrt-Windows.Storage[all] (==2.0.0-beta.1)", "winrt-Windows.System[all] (==2.0.0-beta.1)"]
+
+[[package]]
+name = "wrapt"
+version = "1.16.0"
+description = "Module for decorators, wrappers and monkey patching."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"},
+ {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"},
+ {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"},
+ {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"},
+ {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"},
+ {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"},
+ {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"},
+ {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"},
+ {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"},
+ {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"},
+ {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"},
+ {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"},
+ {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"},
+ {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"},
+ {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"},
+ {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"},
+ {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"},
+ {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"},
+ {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"},
+ {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"},
+ {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"},
+ {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"},
+ {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"},
+ {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"},
+ {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"},
+ {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"},
+ {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"},
+ {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"},
+ {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"},
+ {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"},
+ {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"},
+ {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"},
+ {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"},
+ {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"},
+ {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"},
+ {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"},
+ {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"},
+ {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"},
+ {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"},
+ {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"},
+ {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"},
+ {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"},
+ {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"},
+ {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"},
+ {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"},
+ {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"},
+ {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"},
+ {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"},
+ {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"},
+ {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"},
+ {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"},
+ {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"},
+ {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"},
+ {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"},
+ {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"},
+ {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"},
+ {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"},
+ {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"},
+ {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"},
+ {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"},
+ {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"},
+ {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"},
+ {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"},
+ {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"},
+ {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"},
+ {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"},
+ {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"},
+ {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"},
+ {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"},
+ {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"},
+]
+
+[[package]]
+name = "yarg"
+version = "0.1.9"
+description = "A semi hard Cornish cheese, also queries PyPI (PyPI client)"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+ {file = "yarg-0.1.9-py2.py3-none-any.whl", hash = "sha256:4f9cebdc00fac946c9bf2783d634e538a71c7d280a4d806d45fd4dc0ef441492"},
+ {file = "yarg-0.1.9.tar.gz", hash = "sha256:55695bf4d1e3e7f756496c36a69ba32c40d18f821e38f61d028f6049e5e15911"},
+]
+
+[package.dependencies]
+requests = "*"
+
+[metadata]
+lock-version = "2.0"
+python-versions = ">=3.11.0,<3.13" # Bleak requires version < 3.13
+content-hash = "f990ced5905c62eeebf0a963772ae5238a98ac78e84b9666f2ab45fe38abf363"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5a304c7
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,50 @@
+[tool.poetry]
+name = "plejd-mqtt-ha"
+version = "0.0.1"
+description = ""
+authors = ["Viktor Karlquist "]
+readme = "README.md"
+packages = [{include = "plejd_mqtt_ha"}]
+
+[tool.poetry.dependencies]
+python = ">=3.11.0,<3.13" # Bleak requires version < 3.13
+paho-mqtt = "^1.6.1" # MQTT library
+ha-mqtt-discoverable = "^0.12.0" # Create MQTT entities that are discovered by HA
+bleak = "^0.21.1" # BLE library
+requests = "^2.31.0" # To communicate with plejd API
+tomli = "^2.0.1" # TOML parser to store plejd site
+numpy = "^1.25.2" # To parse BLE payload, could probably be done without numpy
+cryptography = "^41.0.5" # Used to create AES cipher to authenticate against plejd mesh
+pydantic = "^1.10.13" # Data validation
+
+[tool.poetry.group.dev.dependencies]
+mypy = "^1.5.1" # Type checking
+pylint = "^2.17.5" # Linting
+pycodestyle = "^2.11.0" # Code style
+pytest = "^7.4.3" # Tests
+
+[tool.pylint.format]
+max-line-length = 100
+
+[tool.flake8]
+max-line-length = 100
+
+[tool.black]
+line-length = 100
+
+[tool.pylint.'MESSAGES CONTROL']
+disable="fixme"
+
+[tool.pylint.MASTER]
+ignore="tools"
+
+[tool.mypy]
+warn_return_any = true
+warn_unused_configs = true
+
+[tool.poetry.group.test.dependencies]
+pytest-mock = "^3.6.1"
+pytest-asyncio = "^0.23.2"
+
+[tool.pytest.ini_options]
+testpaths = "tests"
diff --git a/tests/test_bt_client.py b/tests/test_bt_client.py
new file mode 100644
index 0000000..1bb7cdb
--- /dev/null
+++ b/tests/test_bt_client.py
@@ -0,0 +1,301 @@
+import datetime
+import struct
+
+import pytest
+from plejd_mqtt_ha import constants
+from plejd_mqtt_ha.bt_client import (
+ BTClient,
+ PlejdBluetoothError,
+ PlejdNotConnectedError,
+ UnsupportedCommandError,
+)
+from plejd_mqtt_ha.mdl.bt_device_info import BTDeviceInfo
+from plejd_mqtt_ha.mdl.settings import API, PlejdSettings
+
+
+class TestBTClient:
+ """Test BTClient class"""
+
+ @pytest.fixture(autouse=True)
+ def setup(self):
+ self.settings = PlejdSettings(api=API(user="test_user", password="test_password"))
+ self.bt_client = BTClient("deadbeef", self.settings)
+ self.bt_device = BTDeviceInfo(
+ category="category",
+ model="model",
+ device_id=1,
+ unique_id="unique_id",
+ hardware_id="hardware_id",
+ index=1,
+ name="device",
+ ble_address=1,
+ plejd_id=1,
+ device_type="type",
+ )
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "is_connected,_connect,expected",
+ [
+ (True, True, True), # Already connected
+ (False, True, True), # Connect success
+ (False, False, False), # Connect failed
+ ],
+ )
+ async def test_connect(self, mocker, is_connected, _connect, expected):
+ """Test connect method of BTClient"""
+ self.bt_client.is_connected = mocker.MagicMock(return_value=is_connected)
+ self.bt_client._connect = mocker.AsyncMock(return_value=_connect)
+ self.bt_client._stay_connected_loop = mocker.MagicMock()
+
+ result = await self.bt_client.connect(stay_connected=False)
+
+ assert result == expected
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "is_connected,_disconnect,expected",
+ [
+ (True, True, True), # Already connected, disconnect should be successful
+ (False, True, True), # Not connected, disconnect should still return True
+ (True, False, False), # Connected, disconnect should return False
+ (False, False, True), # Not connected, no disconnect should be attempted
+ ],
+ )
+ async def test_disconnect(self, mocker, is_connected, _disconnect, expected):
+ """Test disconnect method of BTClient"""
+ self.bt_client.is_connected = mocker.MagicMock(return_value=is_connected)
+ self.bt_client._client = mocker.MagicMock()
+ self.bt_client._client.disconnect = mocker.AsyncMock(return_value=_disconnect)
+
+ result = await self.bt_client.disconnect()
+
+ assert result == expected
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "is_connected,command,write_request_success,expected_exception",
+ [
+ (False, 0, True, PlejdNotConnectedError), # Not connected
+ (True, -1, True, UnsupportedCommandError), # Invalid command
+ (
+ True,
+ constants.PlejdCommand.BLE_CMD_STATE_CHANGE,
+ False,
+ PlejdBluetoothError,
+ ), # Write request failed
+ (
+ True,
+ constants.PlejdCommand.BLE_CMD_STATE_CHANGE,
+ True,
+ None,
+ ), # Successful case
+ ],
+ )
+ async def test_send_command(
+ self, mocker, is_connected, command, write_request_success, expected_exception
+ ):
+ """Test send_command method of BTClient"""
+ self.bt_client.is_connected = mocker.MagicMock(return_value=is_connected)
+ self.bt_client._get_cmd_payload = mocker.MagicMock(return_value="payload")
+ self.bt_client._client = mocker.MagicMock(address="address")
+ self.bt_client._encode_address = mocker.MagicMock(return_value="encoded_address")
+ self.bt_client._encrypt_decrypt_data = mocker.MagicMock(return_value="encrypted_data")
+ self.bt_client._write_request = mocker.AsyncMock(
+ side_effect=PlejdBluetoothError("Write request failed")
+ if not write_request_success
+ else None
+ )
+
+ if expected_exception:
+ with pytest.raises(expected_exception):
+ await self.bt_client.send_command(1, command, "data", 1)
+ else:
+ await self.bt_client.send_command(1, command, "data", 1)
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "write_request_success, read_request_success, pong_data, expected",
+ [
+ (False, True, b"\x01", False), # Write request failed
+ (True, False, b"\x01", False), # Read request failed
+ (True, True, b"", False), # Pong data is empty
+ (True, True, b"\x02", False), # Pong data is not the expected value
+ (True, True, b"\x01", True), # Successful case
+ ],
+ )
+ async def test_ping(
+ self, mocker, write_request_success, read_request_success, pong_data, expected
+ ):
+ """Test ping method of BTClient"""
+ ping_data = b"\x00"
+ mocker.patch("plejd_mqtt_ha.bt_client.randbytes", return_value=ping_data)
+ self.bt_client._write_request = mocker.AsyncMock(
+ side_effect=PlejdBluetoothError("Write request failed")
+ if not write_request_success
+ else None
+ )
+
+ self.bt_client._read_request = mocker.AsyncMock(
+ side_effect=PlejdBluetoothError("Read request failed")
+ if not read_request_success
+ else None,
+ return_value=pong_data,
+ )
+ result = await self.bt_client.ping()
+
+ assert result == expected
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "is_connected, read_request_success, encrypted_data, expected_exception",
+ [
+ (False, True, b"\x01", PlejdNotConnectedError), # Not connected
+ (True, False, b"\x01", PlejdBluetoothError), # _read_request failed
+ (True, True, b"\x01", None), # Successful case
+ ],
+ )
+ async def test_get_last_data(
+ self,
+ mocker,
+ is_connected,
+ read_request_success,
+ encrypted_data,
+ expected_exception,
+ ):
+ """Test get_last_data method of BTClient"""
+ self.bt_client.is_connected = mocker.MagicMock(return_value=is_connected)
+ self.bt_client._client = mocker.MagicMock(address="address")
+ self.bt_client._encode_address = mocker.MagicMock(return_value="encoded_address")
+ self.bt_client._encrypt_decrypt_data = mocker.MagicMock(return_value="decrypted_data")
+ self.bt_client._read_request = mocker.AsyncMock(
+ side_effect=PlejdBluetoothError("Read request failed")
+ if not read_request_success
+ else None,
+ return_value=encrypted_data,
+ )
+
+ if expected_exception:
+ with pytest.raises(expected_exception):
+ await self.bt_client.get_last_data()
+ else:
+ result = await self.bt_client.get_last_data()
+ assert result == "decrypted_data"
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "is_connected,"
+ "send_command_success,"
+ "get_last_data_success,"
+ "last_data,"
+ "expected_exception,"
+ "expected_result",
+ [
+ (
+ False,
+ True,
+ True,
+ b"\x01\x02\x03\x04\x05\x06\x07\x08",
+ PlejdNotConnectedError,
+ None,
+ ), # Not connected
+ (
+ True,
+ False,
+ True,
+ b"\x01\x02\x03\x04\x05\x06\x07\x08",
+ PlejdBluetoothError,
+ None,
+ ), # send_command failed
+ (
+ True,
+ True,
+ False,
+ b"\x01\x02\x03\x04\x05\x06\x07\x08",
+ PlejdBluetoothError,
+ None,
+ ), # get_last_data failed
+ (
+ True,
+ True,
+ True,
+ b"\x01\x02\x03\x04\x05\x06\x07\x08",
+ UnsupportedCommandError,
+ None,
+ ), # Invalid last_data
+ (
+ True,
+ True,
+ True,
+ b"\x01\x02\x00\x00\x1b\x00\x00\x00\x00\x00\x00\x00",
+ None,
+ datetime.datetime.fromtimestamp(0),
+ ), # Successful case
+ ],
+ )
+ async def test_get_plejd_time(
+ self,
+ mocker,
+ is_connected,
+ send_command_success,
+ get_last_data_success,
+ last_data,
+ expected_exception,
+ expected_result,
+ ):
+ """Test get_plejd_time method of BTClient"""
+
+ self.bt_client.is_connected = mocker.MagicMock(return_value=is_connected)
+ self.bt_client.send_command = mocker.AsyncMock(
+ side_effect=PlejdBluetoothError("send_command failed")
+ if not send_command_success
+ else None
+ )
+ self.bt_client.get_last_data = mocker.AsyncMock(
+ side_effect=PlejdBluetoothError("get_last_data failed")
+ if not get_last_data_success
+ else None,
+ return_value=last_data,
+ )
+
+ if expected_exception:
+ with pytest.raises(expected_exception):
+ await self.bt_client.get_plejd_time(self.bt_device)
+ else:
+ result = await self.bt_client.get_plejd_time(self.bt_device)
+ assert result == expected_result
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "send_command_success, expected_exception, expected_result",
+ [
+ (False, PlejdNotConnectedError, False), # Not connected
+ (False, PlejdBluetoothError, False), # send_command failed
+ (True, None, True), # Successful case
+ ],
+ )
+ async def test_set_plejd_time(
+ self, mocker, send_command_success, expected_exception, expected_result
+ ):
+ """Test set_plejd_time method of BTClient"""
+
+ time = datetime.datetime.now()
+ timestamp = struct.pack("= 400:
+ mock_response.raise_for_status.side_effect = requests.HTTPError()
+ mocker.patch("requests.post", return_value=mock_response)
+
+ api = PlejdAPI(self.settings)
+ if expected == IncorrectCredentialsError:
+ with pytest.raises(expected):
+ api.login()
+ elif expected == UnknownResponseError:
+ with pytest.raises(expected):
+ api.login()
+ elif expected == PlejdAPIError:
+ with pytest.raises(PlejdAPIError):
+ api.login()
+ else:
+ api.login()
+ assert api._session_token == expected
+
+ # TODO add positive test for get_site
+
+ @pytest.mark.parametrize(
+ "exception",
+ [requests.RequestException, ValueError, UnknownResponseError("")],
+ )
+ def test_get_site_exceptions(self, mocker, exception):
+ """Test get_site method of PlejdAPI when an exception is raised"""
+ api = PlejdAPI(self.settings)
+ api._session_token = "test_token"
+
+ mocker.patch("requests.post", side_effect=exception)
+ with pytest.raises(PlejdAPIError):
+ api.get_site()