diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..44c143e --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,37 @@ +FROM mcr.microsoft.com/devcontainers/base:debian + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Install dependencies needed by Home Assistant or its dependencies +RUN \ + apt-get update \ + && DEBIAN_FRONTEND=noninteractive \ + apt-get install -y --no-install-recommends \ + build-essential \ + clang \ + cmake \ + ffmpeg \ + gcc \ + git \ + libavcodec-dev \ + libavdevice-dev \ + libavfilter-dev \ + libavformat-dev \ + libavutil-dev \ + libffi-dev \ + liblzma-dev \ + libpcap-dev \ + libssl-dev \ + libswresample-dev \ + libswscale-dev \ + libturbojpeg0 \ + libudev-dev \ + libxml2 \ + libxmlsec1-dev \ + libyaml-dev \ + lzma-dev \ + uuid-dev \ + webp \ + xz-utils \ + zlib1g-dev \ + pre-commit diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c3120f4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,81 @@ +{ + "name": "Development environment", + "dockerFile": "./Dockerfile", + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "installDirectlyFromGitHubRelease": true, + "version": "latest" + }, + "ghcr.io/devcontainers/features/node:1": { + "nodeGypDependencies": false, + "version": "lts" + }, + "ghcr.io/schlich/devcontainer-features/rye:1": { + "uv": true + } + }, + "forwardPorts": [8123], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "openBrowserOnce" + } + }, + "runArgs": [ + "-e", + "GIT_EDITOR=code --wait", + "--userns=keep-id:uid=1000,gid=1000", + "--privileged", + "-v", + "/dev/ttyUSB0:/dev/ttyUSB0" + ], + "onCreateCommand": "rye config --set-bool behavior.use-uv=true && npm install -g mystmd", + "updateContentCommand": "rye sync", + "containerEnv": { + "DEVCONTAINER": "1", + "RYE_HOME": "/home/vscode/.rye" + }, + "containerUser": "vscode", + "remoteUser": "vscode", + "updateRemoteUserUID": true, + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "esbenp.prettier-vscode", + "GitHub.copilot", + "GitHub.vscode-github-actions", + "GitHub.vscode-pull-request-github", + "ms-python.pylint", + "ms-python.python", + "ms-python.vscode-pylance", + "redhat.vscode-yaml", + "tamasfe.even-better-toml", + "yzhang.markdown-all-in-one" + ], + "settings": { + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", + "yaml.customTags": [ + "!input scalar", + "!secret scalar", + "!include_dir_named scalar", + "!include_dir_list scalar", + "!include_dir_merge_list scalar", + "!include_dir_merge_named scalar" + ], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } + } + } + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..c21a496 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,55 @@ +--- +name: "Bug report" +description: "Report a bug with the integration" +labels: "Bug" +body: + - type: markdown + attributes: + value: Before you open a new issue, search through the existing issues to see if others have had the same problem. + - type: textarea + attributes: + label: "System Health details" + description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" + validations: + required: true + - type: checkboxes + attributes: + label: Checklist + options: + - label: I have enabled debug logging for my installation. + required: true + - label: I have filled out the issue template to the best of my ability. + required: true + - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). + required: true + - label: This issue is not a duplicate issue of any [previous issues](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Bug%22+).. + required: true + - type: textarea + attributes: + label: "Describe the issue" + description: "A clear and concise description of what the issue is." + validations: + required: true + - type: textarea + attributes: + label: Reproduction steps + description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: textarea + attributes: + label: "Debug logs" + description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." + render: text + validations: + required: true + + - type: textarea + attributes: + label: "Diagnostics dump" + description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 1187e20..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: Aohzan - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**Configuration** -From UI or in configuration.yaml (paste it here between ```xxx```) - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Debug logs** -![image](https://github.com/Aohzan/ipx800v5/assets/2736322/4e2a69a3-db12-4c6d-9aa5-a96aa2e4a2e1) -``` -Paste debug logs here -``` diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 9e51880..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: enhancement -assignees: Aohzan - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..31a15ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +--- +name: "Feature request" +description: "Suggest an idea for this project" +labels: "Feature+Request" +body: + - type: markdown + attributes: + value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. + - type: checkboxes + attributes: + label: Checklist + options: + - label: I have filled out the template to the best of my ability. + required: true + - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). + required: true + - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). + required: true + + - type: textarea + attributes: + label: "Is your feature request related to a problem? Please describe." + description: "A clear and concise description of what the problem is." + placeholder: "I'm always frustrated when [...]" + validations: + required: true + + - type: textarea + attributes: + label: "Describe the solution you'd like" + description: "A clear and concise description of what you want to happen." + validations: + required: true + + - type: textarea + attributes: + label: "Describe alternatives you've considered" + description: "A clear and concise description of any alternative solutions or features you've considered." + validations: + required: true + + - type: textarea + attributes: + label: "Additional context" + description: "Add any other context or screenshots about the feature request here." + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c23693b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ff066fb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: "Lint" + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + ruff: + name: "Ruff" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Set up Python" + uses: actions/setup-python@v4.7.1 + with: + python-version: "3.12" + cache: "pip" + + - name: "Install requirements" + run: python3 -m pip install -r requirements.txt + + - name: "Run" + run: python3 -m ruff check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5f27118 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: "Release" + +on: + release: + types: + - "published" + +permissions: {} + +jobs: + release: + name: "Release" + runs-on: "ubuntu-latest" + permissions: + contents: write + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Adjust version number" + shell: "bash" + run: | + yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ + "${{ github.workspace }}/custom_components/integration_blueprint/manifest.json" + + - name: "ZIP the integration directory" + shell: "bash" + run: | + cd "${{ github.workspace }}/custom_components/integration_blueprint" + zip integration_blueprint.zip -r ./ + + - name: "Upload the ZIP file to the release" + uses: softprops/action-gh-release@v0.1.15 + with: + files: ${{ github.workspace }}/custom_components/integration_blueprint/integration_blueprint.zip diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..f7d3232 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,35 @@ +name: "Validate" + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest + name: "Hassfest Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Run hassfest validation" + uses: "home-assistant/actions/hassfest@master" + + hacs: # https://github.com/hacs/action + name: "HACS Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Run HACS validation" + uses: "hacs/action@main" + with: + category: "integration" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d09b04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# artifacts +__pycache__ +.pytest* +*.egg-info +*/build/* +*/dist/* + + +# misc +.coverage +.vscode/settings.json +coverage.xml + + +# Home Assistant configuration +config/* +!config/configuration.yaml +requirements_all.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a2a563e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,70 @@ +--- +repos: + - repo: local + hooks: + - id: ruff-check + name: 🐶 Ruff Linter + language: system + types: [python] + entry: rye run ruff check --fix + require_serial: true + stages: [commit, push, manual] + - id: ruff-format + name: 🐶 Ruff Formatter + language: system + types: [python] + entry: rye run ruff format + require_serial: true + stages: [commit, push, manual] + - id: check-json + name: { Check JSON files + language: system + types: [json] + entry: rye run check-json + ignore: .devcontainer/devcontainer.json + - id: check-toml + name: ✅ Check TOML files + language: system + types: [toml] + entry: rye run check-toml + - id: check-yaml + name: ✅ Check YAML files + language: system + types: [yaml] + entry: rye run check-yaml + - id: check-merge-conflict + name: 💥 Check for merge conflicts + language: system + types: [text] + entry: rye run check-merge-conflict + - id: check-symlinks + name: 🔗 Check for broken symlinks + language: system + types: [symlink] + entry: rye run check-symlinks + - id: end-of-file-fixer + name: ⮐ Fix End of Files + language: system + types: [text] + entry: rye run end-of-file-fixer + stages: [commit, push, manual] + - id: no-commit-to-branch + name: 🛑 Don't commit to main branch + language: system + entry: rye run no-commit-to-branch + pass_filenames: false + always_run: true + args: + - --branch=main + - id: pylint + name: 🌟 Starring code with pylint + language: system + types: [python] + entry: rye run pylint + ignore: + - R0913 + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.7.1 + hooks: + - id: prettier + name: 🎨 Format using prettier diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..b8b5067 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,48 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py310" + +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +[flake8-pytest-style] +fixture-parentheses = false + +[pyupgrade] +keep-runtime-typing = true + +[mccabe] +max-complexity = 25 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..54fc721 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Home Assistant", + "type": "debugpy", + "request": "launch", + "python": "${workspaceFolder}/.venv/bin/python", + "module": "homeassistant", + "cwd": "${workspaceFolder}", + "justMyCode": false, + "args": ["--debug", "-c", "config"] + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index e32db9c..6d2f91e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # CHANGELOG +## 0.7.0 + +- Fix default conf missing error during initialization +- Lint and code improvments +- Bump pyserial-asyncio +- Add service translation +- Handle no USB device found during init +- Replace the jamming sensor by a binary_sensor and add events catching to it +- Allow to specify manual path to USB device +- Disallow multi instance of the integration +- Fix options changes not reloading integration + ## 0.6.1 - Fix incapacity to create switch entity when automatic entity add is disabled at bootstrap 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 index b877b5b..e1a2fbd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ # HA_RFPlayer -RFPlayer custom component/integration for Home assistant +RFPlayer custom component/integration for Home Assistant ## Installation -Copy the `custom_components/rfplayer` folder in your config directory. +### HACS + +[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=gce-electronics&repository=HA_RFPlayer&category=integration) + +### Manually + +Copy the `custom_components/rfplayer` folder in `config/custom_components` of your Home Assistant + +## Configuration Go to Home-Assistant UI, Configuration > Integrations, button (+ Add Integration) and search GCE RFPlayer diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..205f160 --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,12 @@ +--- +# default_config: +config: +energy: +history: +logbook: +usb: + +logger: + default: info + logs: + custom_components.rfplayer: debug diff --git a/custom_components/rfplayer/__init__.py b/custom_components/rfplayer/__init__.py index dc4d38f..8f96b11 100644 --- a/custom_components/rfplayer/__init__.py +++ b/custom_components/rfplayer/__init__.py @@ -1,14 +1,13 @@ """Support for Rfplayer devices.""" -import asyncio -from collections import defaultdict + +from asyncio import BaseTransport, timeout import copy import logging +from typing import Any -import async_timeout -from serial import SerialException -from homeassistant.util import slugify import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_STATE, @@ -19,15 +18,15 @@ CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CoreState, callback +from homeassistant.core import CoreState, HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import slugify from .const import ( CONF_AUTOMATIC_ADD, @@ -40,6 +39,7 @@ EVENT_BUTTON_PRESSED, EVENT_KEY_COMMAND, EVENT_KEY_ID, + EVENT_KEY_JAMMING, EVENT_KEY_SENSOR, PLATFORMS, RFPLAYER_PROTOCOL, @@ -48,7 +48,11 @@ SIGNAL_EVENT, SIGNAL_HANDLE_EVENT, ) -from .rflib.rfpprotocol import create_rfplayer_connection +from .rflib.rfpprotocol import ( + ProtocolBase, + RfplayerProtocol, + create_rfplayer_connection, +) _LOGGER = logging.getLogger(__name__) @@ -59,7 +63,7 @@ vol.Required(CONF_COMMAND): cv.string, vol.Optional(CONF_DEVICE_ADDRESS): cv.string, vol.Optional(CONF_DEVICE_ID): cv.string, - vol.Required(CONF_AUTOMATIC_ADD, default=False): cv.boolean, + vol.Required(CONF_AUTOMATIC_ADD, default=False): cv.boolean, # type: ignore } ) @@ -69,6 +73,8 @@ def identify_event_type(event): Async friendly. """ + if "JAMMING_" in event.get(EVENT_KEY_ID): + return EVENT_KEY_JAMMING if EVENT_KEY_COMMAND in event: return EVENT_KEY_COMMAND if EVENT_KEY_SENSOR in event: @@ -76,11 +82,26 @@ def identify_event_type(event): return "unknown" -async def async_setup_entry(hass, entry): +# pylint: disable-next=too-many-statements +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up GCE RFPlayer from a config entry.""" + config = entry.data options = entry.options + hass.data.setdefault( + DOMAIN, + { + CONF_DEVICE: config[CONF_DEVICE], + DATA_ENTITY_LOOKUP: { + EVENT_KEY_COMMAND: {}, + EVENT_KEY_SENSOR: {}, + EVENT_KEY_JAMMING: {}, + }, + DATA_DEVICE_REGISTER: {}, + }, + ) + async def async_send_command(call): """Send Rfplayer command.""" _LOGGER.debug("Rfplayer send command for %s", str(call.data)) @@ -96,7 +117,9 @@ async def async_send_command(call): event_id = "_".join( [ call.data[CONF_PROTOCOL], - call.data.get(CONF_DEVICE_ID) or call.data.get(CONF_DEVICE_ADDRESS), + call.data.get(CONF_DEVICE_ID) + or call.data.get(CONF_DEVICE_ADDRESS) + or call.data.get(CONF_COMMAND), ] ) device = { @@ -132,23 +155,21 @@ def event_callback(event): # Lookup entities who registered this device id as device id or alias event_id = event.get(EVENT_KEY_ID) - entity_id = hass.data[DOMAIN][DATA_ENTITY_LOOKUP][event_type][event_id] + entity_id = hass.data[DOMAIN][DATA_ENTITY_LOOKUP][event_type].get(event_id) if entity_id: # Propagate event to every entity matching the device id _LOGGER.debug("passing event to %s", entity_id) async_dispatcher_send(hass, SIGNAL_HANDLE_EVENT.format(entity_id), event) + elif event_type in hass.data[DOMAIN][DATA_DEVICE_REGISTER]: + _LOGGER.debug("event_id not known, adding new device") + hass.data[DOMAIN][DATA_ENTITY_LOOKUP][event_type][event_id] = event + _add_device_to_base_config(event, event_id) + hass.async_create_task( + hass.data[DOMAIN][DATA_DEVICE_REGISTER][event_type](event) + ) else: - # If device is not yet known, register with platform (if loaded) - if event_type in hass.data[DOMAIN][DATA_DEVICE_REGISTER]: - _LOGGER.debug("device_id not known, adding new device") - hass.data[DOMAIN][DATA_ENTITY_LOOKUP][event_type][event_id] = event - _add_device_to_base_config(event, event_id) - hass.async_create_task( - hass.data[DOMAIN][DATA_DEVICE_REGISTER][event_type](event) - ) - else: - _LOGGER.debug("device_id not known and automatic add disabled") + _LOGGER.debug("event_id not known and automatic add disabled") @callback def _add_device_to_base_config(event, event_id): @@ -162,6 +183,8 @@ def _add_device_to_base_config(event, event_id): def reconnect(exc=None): """Schedule reconnect after connection has been unexpectedly lost.""" # Reset protocol binding before starting reconnect + if exc: + _LOGGER.error(exc) hass.data[DOMAIN][RFPLAYER_PROTOCOL] = None async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) @@ -180,19 +203,18 @@ async def connect(): disconnect_callback=reconnect, loop=hass.loop, ) + transport: BaseTransport + protocol: ProtocolBase try: - with async_timeout.timeout(CONNECTION_TIMEOUT): + async with timeout(CONNECTION_TIMEOUT): transport, protocol = await connection - except ( - SerialException, - OSError, - asyncio.TimeoutError, - ) as exc: + except (TimeoutError, OSError) as exc: reconnect_interval = config[CONF_RECONNECT_INTERVAL] _LOGGER.exception( - "Error connecting to Rfplayer, reconnecting in %s", reconnect_interval + "Error connecting to Rfplayer, reconnecting in %s", + reconnect_interval, ) # Connection to Rfplayer device is lost, make entities unavailable async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) @@ -204,15 +226,9 @@ async def connect(): # mark entities as available async_dispatcher_send(hass, SIGNAL_AVAILABILITY, True) - hass.data[DOMAIN] = { - RFPLAYER_PROTOCOL: protocol, - CONF_DEVICE: config[CONF_DEVICE], - DATA_ENTITY_LOOKUP: { - EVENT_KEY_COMMAND: defaultdict(list), - EVENT_KEY_SENSOR: defaultdict(list), - }, - DATA_DEVICE_REGISTER: {}, - } + hass.data[DOMAIN][RFPLAYER_PROTOCOL] = protocol + + entry.add_update_listener(_async_update_listener) if options.get(CONF_AUTOMATIC_ADD, config[CONF_AUTOMATIC_ADD]) is True: for device_type in "sensor", "command": @@ -234,42 +250,72 @@ async def connect(): return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + hass.data.pop(DOMAIN) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +# pylint: disable-next=too-many-instance-attributes class RfplayerDevice(RestoreEntity): """Representation of a Rfplayer device. Contains the common logic for Rfplayer entities. """ - platform = None - _state = None _available = True + _attr_assumed_state = True + _attr_should_poll = False + # pylint: disable-next=too-many-arguments def __init__( self, - protocol, - device_address=None, - device_id=None, - initial_event=None, - name=None, - ): + protocol: str, + device_address: str | None = None, + device_id: str | None = None, + initial_event: dict[str, Any] | None = None, + name: str | None = None, + unique_id: str | None = None, + ) -> None: """Initialize the device.""" - # Rflink specific attributes for every component type + # Rfplayer specific attributes for every component type + if not (name or device_address or device_id) or not ( + unique_id or device_address or device_id + ): + raise TypeError("Incorrect arguments for RfplayerDevice") self._initial_event = initial_event self._protocol = protocol self._device_id = device_id self._device_address = device_address self._event = None - self._state: bool = None - self._attr_assumed_state = True - if name is not None: - self._attr_name = name - self._attr_unique_id = slugify(f"{protocol}_{name}") - else: - self._attr_name = f"{protocol} {device_id or device_address}" - self._attr_unique_id = slugify(f"{protocol}_{device_id or device_address}") - async def _async_send_command(self, command, *args): - rfplayer = self.hass.data[DOMAIN][RFPLAYER_PROTOCOL] + self._attr_name = name or f"{protocol} {device_address or device_id}" + self._attr_unique_id = slugify( + "_".join( + [protocol, unique_id or device_address or device_id] # type: ignore[list-item] + ) + ) + + @property + def device_info(self) -> DeviceInfo: + """Return device registry information for this entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self.hass.data[DOMAIN][CONF_DEVICE])}, + manufacturer="GCE", + model="RFPlayer", + name="RFPlayer", + ) + + async def _async_send_command(self, command) -> None: + rfplayer: RfplayerProtocol = self.hass.data[DOMAIN][RFPLAYER_PROTOCOL] await rfplayer.send_command_ack( command=command, protocol=self._protocol, @@ -278,7 +324,7 @@ async def _async_send_command(self, command, *args): ) @callback - def handle_event_callback(self, event): + def handle_event_callback(self, event: dict[str, Any]) -> None: """Handle incoming event for device type.""" # Call platform specific event handler self._handle_event(event) @@ -296,46 +342,23 @@ def handle_event_callback(self, event): "Fired bus event for %s: %s", self.entity_id, event[EVENT_KEY_COMMAND] ) - def _handle_event(self, event): + def _handle_event(self, event) -> None: """Platform specific event handler.""" - raise NotImplementedError() - - @property - def should_poll(self): - """No polling needed.""" - return False + raise NotImplementedError @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - identifiers={ - ( - DOMAIN, - self.hass.data[DOMAIN][ - CONF_DEVICE - ], # + "_" + self._attr_unique_id, - ) - }, - manufacturer="GCE", - model="RFPlayer", - name="RFPlayer", - ) - - @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return bool(self._protocol) @callback - def _availability_callback(self, availability): + def _availability_callback(self, availability) -> None: """Update availability state.""" self._available = availability self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register update callback.""" - await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_AVAILABILITY, self._availability_callback @@ -352,13 +375,3 @@ async def async_added_to_hass(self): # Process the initial event now that the entity is created if self._initial_event: self.handle_event_callback(self._initial_event) - - async def async_will_remove_from_hass(self): - """Clean when entity removed.""" - await super().async_will_remove_from_hass() - device_registry = dr.async_get(self.hass) - device = device_registry.async_get_device( - (DOMAIN, self.hass.data[DOMAIN][CONF_DEVICE] + "_" + self._attr_unique_id) - ) - if device: - device_registry.async_remove_device(device) diff --git a/custom_components/rfplayer/binary_sensor.py b/custom_components/rfplayer/binary_sensor.py new file mode 100644 index 0000000..f0b1099 --- /dev/null +++ b/custom_components/rfplayer/binary_sensor.py @@ -0,0 +1,61 @@ +"""Support for Rfplayer binary sensors.""" + +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RfplayerDevice +from .const import DATA_ENTITY_LOOKUP, DOMAIN, EVENT_KEY_JAMMING + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + _hass: HomeAssistant, _entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Rfplayer platform.""" + # add jamming entity + async_add_entities([RfplayerJammingBinarySensor()]) + + +class RfplayerJammingBinarySensor(RfplayerDevice, BinarySensorEntity): + """Representation of a Rfplayer jamming binary sensor.""" + + entity_description = BinarySensorEntityDescription( + key="jamming_detection", + translation_key="jamming_detection", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:waveform", + ) + _attr_is_on = False + + def __init__(self) -> None: + """Init the jamming sensor rfplayer entity.""" + # Get Jamming events by simulating event id + super().__init__( + protocol="JAMMING", + unique_id="jamming_detection", + name="Jamming detection", + ) + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + # Register id and aliases + await super().async_added_to_hass() + + self.hass.data[DOMAIN][DATA_ENTITY_LOOKUP][EVENT_KEY_JAMMING][ + "JAMMING_0_cmd" + ] = self.entity_id + + def _handle_event(self, event): + """Domain specific event handler.""" + self._attr_is_on = bool(event["value"] == "1") diff --git a/custom_components/rfplayer/config_flow.py b/custom_components/rfplayer/config_flow.py index 834326b..da64e8d 100644 --- a/custom_components/rfplayer/config_flow.py +++ b/custom_components/rfplayer/config_flow.py @@ -1,39 +1,55 @@ """Config flow to configure the rfplayer integration.""" + import os +from typing import Any import serial import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import exceptions +from homeassistant.config_entries import HANDLERS, ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_DEVICE, CONF_DEVICES from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from .const import ( CONF_AUTOMATIC_ADD, + CONF_MANUAL_DEVICE, CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL, DOMAIN, ) -@config_entries.HANDLERS.register(DOMAIN) -class RfplayerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +@HANDLERS.register(DOMAIN) +class RfplayerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a rfplayer config flow.""" VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Config flow started from UI.""" - errors = {} + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + schema_errors: dict[str, Any] = {} if user_input is not None: user_input[CONF_DEVICES] = {} - user_input[CONF_DEVICE] = await self.hass.async_add_executor_job( - get_serial_by_id, user_input[CONF_DEVICE] - ) + if user_input.get(CONF_DEVICE): + user_input[CONF_DEVICE] = await self.hass.async_add_executor_job( + get_serial_by_id, user_input[CONF_DEVICE] + ) + elif user_input.get(CONF_MANUAL_DEVICE): + user_input[CONF_DEVICE] = user_input[CONF_MANUAL_DEVICE] + user_input.pop(CONF_MANUAL_DEVICE) + else: + schema_errors.update({CONF_DEVICE: "device_missing"}) - if not errors: + if not schema_errors: return self.async_create_entry( title=user_input[CONF_DEVICE], data=user_input ) @@ -41,14 +57,17 @@ async def async_step_user(self, user_input=None): ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) list_of_ports = {} for port in ports: - list_of_ports[ - port.device - ] = f"{port}, s/n: {port.serial_number or 'n/a'}" + ( - f" - {port.manufacturer}" if port.manufacturer else "" + list_of_ports[port.device] = ( + f"{port}, s/n: {port.serial_number or 'n/a'}" + + (f" - {port.manufacturer}" if port.manufacturer else "") ) + if not list_of_ports: + raise AbortFlow("no_devices_found") + data = { - vol.Required(CONF_DEVICE): vol.In(list_of_ports), + vol.Optional(CONF_DEVICE): vol.In(list_of_ports), + vol.Optional(CONF_MANUAL_DEVICE): str, vol.Required(CONF_AUTOMATIC_ADD, default=True): bool, vol.Required( CONF_RECONNECT_INTERVAL, default=DEFAULT_RECONNECT_INTERVAL @@ -57,24 +76,26 @@ async def async_step_user(self, user_input=None): return self.async_show_form( step_id="user", data_schema=vol.Schema(data), - errors=errors, + errors=schema_errors, ) @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Define the config flow to handle options.""" return RfPlayerOptionsFlowHandler(config_entry) -class RfPlayerOptionsFlowHandler(config_entries.OptionsFlow): +class RfPlayerOptionsFlowHandler(OptionsFlow): """Handle a RFPLayer options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry - async def async_step_init(self, user_input: dict = None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is None: config = self.config_entry.data diff --git a/custom_components/rfplayer/const.py b/custom_components/rfplayer/const.py index 381fbc0..3bc01fc 100644 --- a/custom_components/rfplayer/const.py +++ b/custom_components/rfplayer/const.py @@ -1,4 +1,5 @@ """Constants for the rfplayer integration.""" + DOMAIN = "rfplayer" DATA_RFOBJECT = "rfobject" @@ -8,12 +9,13 @@ DEFAULT_RECONNECT_INTERVAL = 10 DEFAULT_SIGNAL_REPETITIONS = 1 -PLATFORMS = ["sensor", "switch", "number"] +PLATFORMS = ["binary_sensor", "number", "sensor", "switch"] ATTR_EVENT = "event" RFPLAYER_PROTOCOL = "rfplayer_protocol" +CONF_MANUAL_DEVICE = "manual_device" CONF_DEVICE_ADDRESS = "device_address" CONF_FIRE_EVENT = "fire_event" CONF_IGNORE_DEVICES = "ignore_devices" @@ -26,6 +28,7 @@ EVENT_BUTTON_PRESSED = "button_pressed" EVENT_KEY_COMMAND = "command" +EVENT_KEY_JAMMING = "jamming" EVENT_KEY_ID = "id" EVENT_KEY_SENSOR = "sensor" EVENT_KEY_UNIT = "unit" diff --git a/custom_components/rfplayer/icons.json b/custom_components/rfplayer/icons.json new file mode 100644 index 0000000..ee45512 --- /dev/null +++ b/custom_components/rfplayer/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "send_command": "mdi:remote" + } +} diff --git a/custom_components/rfplayer/manifest.json b/custom_components/rfplayer/manifest.json index 2975b50..8935fd1 100644 --- a/custom_components/rfplayer/manifest.json +++ b/custom_components/rfplayer/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/gce-electronics/HA_RFPlayer", "iot_class": "assumed_state", "issue_tracker": "https://github.com/gce-electronics/HA_RFPlayer/issues", - "requirements": ["pyserial==3.5", "pyserial-asyncio==0.5"], - "version": "0.6.1" + "requirements": ["pyserial==3.5", "pyserial-asyncio==0.6"], + "version": "0.7.0" } diff --git a/custom_components/rfplayer/number.py b/custom_components/rfplayer/number.py index 8613480..5032060 100644 --- a/custom_components/rfplayer/number.py +++ b/custom_components/rfplayer/number.py @@ -1,53 +1,67 @@ """Support for Rfplayer number.""" + import logging -from homeassistant.components.number import NumberEntity -from homeassistant.core import callback -from homeassistant.helpers.entity import EntityCategory +from homeassistant.components.number import ( + NumberEntityDescription, + NumberMode, + RestoreNumber, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RfplayerDevice from .const import DOMAIN, RFPLAYER_PROTOCOL +from .rflib.rfpprotocol import RfplayerProtocol _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + _hass: HomeAssistant, _entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Rfplayer platform.""" _LOGGER.debug("Add jamming number entity") async_add_entities([RfplayerJammingNumber()]) -class RfplayerJammingNumber(RfplayerDevice, NumberEntity): +class RfplayerJammingNumber(RfplayerDevice, RestoreNumber): """Representation of a Rfplayer jamming number setting entity.""" - def __init__(self): - """Init the number rfplayer entity.""" - self._attr_native_min_value = 0 - self._attr_native_max_value = 10 - self._attr_native_mode = "slider" - self._attr_native_entity_category = EntityCategory.CONFIG - super().__init__("JAMMING", device_id=0, name="Jamming detection level") + entity_description = NumberEntityDescription( + key="jamming_level", + translation_key="jamming_level", + entity_category=EntityCategory.CONFIG, + ) + _attr_native_min_value = 0 + _attr_native_max_value = 10 + _attr_native_step = 1 + _attr_native_value: float | None = None + _attr_mode = NumberMode.SLIDER + + def __init__(self) -> None: + """Init the jamming number rfplayer entity.""" + super().__init__( + protocol="JAMMING", + unique_id="jamming_level", + name="Jamming detection level", + ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore RFPlayer device state.""" await super().async_added_to_hass() - if self._event is None: - old_state = await self.async_get_last_state() - if old_state is not None: - self._state = old_state.state - - @callback - def _handle_event(self, event): - self._state = int(event["value"]) - - @property - def native_value(self): - """Return the current setting.""" - return self._state + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() + ): + if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._attr_native_value = last_number_data.native_value - async def async_set_native_value(self, value) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - rfplayer = self.hass.data[DOMAIN][RFPLAYER_PROTOCOL] - await rfplayer.send_command_ack(command=int(value), protocol=self._protocol) - self._state = value + value = int(max(0, min(10, value))) + rfplayer: RfplayerProtocol = self.hass.data[DOMAIN][RFPLAYER_PROTOCOL] + await rfplayer.send_command_ack(command=str(value), protocol=self._protocol) + self._attr_native_value = value self.async_write_ha_state() diff --git a/custom_components/rfplayer/rflib/__init__.py b/custom_components/rfplayer/rflib/__init__.py index 4ede8e6..a08df97 100644 --- a/custom_components/rfplayer/rflib/__init__.py +++ b/custom_components/rfplayer/rflib/__init__.py @@ -1 +1 @@ -# noqa +"""rflib.""" diff --git a/custom_components/rfplayer/rflib/exception.py b/custom_components/rfplayer/rflib/exception.py new file mode 100644 index 0000000..6f82787 --- /dev/null +++ b/custom_components/rfplayer/rflib/exception.py @@ -0,0 +1,5 @@ +"""RfPlayer exceptions.""" + + +class RfPlayerException(Exception): + """Generic RfPlayer exception.""" diff --git a/custom_components/rfplayer/rflib/rfpparser.py b/custom_components/rfplayer/rflib/rfpparser.py index 966d7ac..dc0b3e2 100644 --- a/custom_components/rfplayer/rflib/rfpparser.py +++ b/custom_components/rfplayer/rflib/rfpparser.py @@ -1,10 +1,13 @@ """Parsers.""" +from collections.abc import Callable, Generator from enum import Enum import json import logging import re -from typing import Any, Callable, Dict, Generator, cast +from typing import Any, cast + +from .exception import RfPlayerException log = logging.getLogger(__name__) @@ -34,20 +37,20 @@ } SBX_STATUS_LOOKUP = { - "0": "eco", - "1": "moderato", - "2": "medio", - "3": "comfort", - "4": "stop", + "0": "eco", + "1": "moderato", + "2": "medio", + "3": "comfort", + "4": "stop", "5": "outoffrost", - "6": "special", + "6": "special", "7": "auto", "8": "centralised", - "9": "outoffrost", # starbox f03: undocumented, like outoffrost + progressive heating ? + "9": "outoffrost", # starbox f03: undocumented, like outoffrost + progressive heating ? } VALUE_TRANSLATION = cast( - Dict[str, Callable[[str], str]], + dict[str, Callable[[str], str]], { "detector": lambda x: DTC_STATUS_LOOKUP.get(x, "unknown"), "starbox": lambda x: SBX_STATUS_LOOKUP.get(x, "unknown"), @@ -67,15 +70,15 @@ packet_header_re = re.compile(PACKET_HEADER_RE) -PacketType = Dict[str, Any] +PacketType = dict[str, Any] class PacketHeader(Enum): """Packet source identification.""" - master = "10" - echo = "11" - gateway = "20" + MASTER = "10" + ECHO = "11" + GATEWAY = "20" def valid_packet(packet: str) -> bool: @@ -83,10 +86,11 @@ def valid_packet(packet: str) -> bool: return bool(packet_header_re.match(packet)) +# pylint: disable-next=too-many-branches too-many-statements def decode_packet(packet: str) -> list: """Decode packet.""" packets_found = [] - data = cast(PacketType, {"node": PacketHeader.gateway.name}) + data = cast(PacketType, {"node": PacketHeader.GATEWAY.name}) # Welcome messages directly send if packet.startswith("ZIA--"): @@ -105,27 +109,29 @@ def decode_packet(packet: str) -> list: elif data["protocol"] in ["X2D"]: data["id"] = message["infos"]["id"] if message["infos"]["subTypeMeaning"] == "Detector/Sensor": - value = VALUE_TRANSLATION["detector"](message["infos"]["qualifier"]) - data["command"] = value - data["state"] = value + value = VALUE_TRANSLATION["detector"](message["infos"]["qualifier"]) + data["command"] = value + data["state"] = value elif message["infos"]["subTypeMeaning"] == "STARBOX F03": - if message["infos"]["functionMeaning"] == "OPERATING MODE": - value = VALUE_TRANSLATION["starbox"](message["infos"]["state"]) - data["command"] = value - data["state"] = value - elif ( message["infos"]["functionMeaning"] == "OTHER FUNCTION" - and message["infos"]["state"] == "6" ): - data["command"] = "assoc:" + message["infos"]["area"] - data["state"] = "assoc:" + message["infos"]["area"] - else: - data["command"] = message["infos"]["functionMeaning"] - data["state"] = message["infos"]["stateMeaning"] + if message["infos"]["functionMeaning"] == "OPERATING MODE": + value = VALUE_TRANSLATION["starbox"](message["infos"]["state"]) + data["command"] = value + data["state"] = value + elif ( + message["infos"]["functionMeaning"] == "OTHER FUNCTION" + and message["infos"]["state"] == "6" + ): + data["command"] = "assoc:" + message["infos"]["area"] + data["state"] = "assoc:" + message["infos"]["area"] + else: + data["command"] = message["infos"]["functionMeaning"] + data["state"] = message["infos"]["stateMeaning"] else: - data["command"] = message["infos"]["subTypeMeaning"] - data["state"] = message["infos"]["qualifier"] + data["command"] = message["infos"]["subTypeMeaning"] + data["state"] = message["infos"]["qualifier"] packets_found.append(data) elif data["protocol"] in ["OREGON"]: - data["id"] = message["infos"]["id_PHY"] + data["id"] = message["infos"]["adr_channel"] data["hardware"] = message["infos"]["id_PHYMeaning"] for measure in message["infos"]["measures"]: measure_data = data.copy() @@ -155,7 +161,7 @@ def encode_packet(packet: PacketType) -> str: return f"ZIA++{command} {protocol} ID {packet['id']}" if "address" in packet: return f"ZIA++{command} {protocol} {packet['address']}" - raise Exception("No ID or Address found") + raise RfPlayerException("No ID or Address found") def serialize_packet_id(packet: PacketType) -> str: @@ -172,7 +178,7 @@ def serialize_packet_id(packet: PacketType) -> str: ) -def deserialize_packet_id(packet_id: str) -> Dict[str, str]: +def deserialize_packet_id(packet_id: str) -> dict[str, str]: """Deserialize packet id.""" if packet_id == "rfplayer": return {"protocol": "unknown"} @@ -224,12 +230,12 @@ def packet_events(packet: PacketType) -> Generator[PacketType, None, None]: # packet["message"] # yield { "id": packet_id, "message": packet["message"] } # except KeyError: - for sensor, value in events.items(): - log.debug("packet_events, sensor:%s,value:%s", sensor, value) - unit = packet.get(sensor + "_unit", None) + for paquet_type, value in events.items(): + log.debug("packet_events, sensor:%s,value:%s", paquet_type, value) + unit = packet.get(paquet_type + "_unit", None) yield { - "id": packet_id + PACKET_ID_SEP + field_abbrev[sensor], - "sensor": sensor, + "id": packet_id + PACKET_ID_SEP + field_abbrev[paquet_type], + "sensor": paquet_type, "value": value, "unit": unit, } diff --git a/custom_components/rfplayer/rflib/rfpprotocol.py b/custom_components/rfplayer/rflib/rfpprotocol.py index 1c214c4..73a5285 100644 --- a/custom_components/rfplayer/rflib/rfpprotocol.py +++ b/custom_components/rfplayer/rflib/rfpprotocol.py @@ -1,16 +1,18 @@ """Asyncio protocol implementation of RFplayer.""" import asyncio +from collections.abc import Callable, Coroutine, Sequence from datetime import timedelta from fnmatch import fnmatchcase from functools import partial import logging -from typing import Any, Callable, Coroutine, Optional, Sequence, Tuple, Type +from typing import Any, cast from serial_asyncio import create_serial_connection from .rfpparser import ( PacketType, + RfPlayerException, decode_packet, encode_packet, packet_events, @@ -25,27 +27,26 @@ class ProtocolBase(asyncio.Protocol): """Manage low level rfplayer protocol.""" - transport = None # type: asyncio.BaseTransport - def __init__( self, - loop: Optional[asyncio.AbstractEventLoop] = None, - disconnect_callback: Optional[Callable[[Optional[Exception]], None]] = None, - **kwargs: Any, + loop: asyncio.AbstractEventLoop | None = None, + disconnect_callback: Callable[[Exception | None], None] | None = None, ) -> None: """Initialize class.""" if loop: self.loop = loop else: self.loop = asyncio.get_event_loop() + self.transport: asyncio.WriteTransport | None = None self.packet = "" self.buffer = "" - self.packet_callback = None # type: Optional[Callable[[PacketType], None]] + self.packet_callback: Callable[[PacketType], None] | None = None self.disconnect_callback = disconnect_callback def connection_made(self, transport: asyncio.BaseTransport) -> None: """Just logging for now.""" - self.transport = transport + + self.transport = cast(asyncio.WriteTransport, transport) log.debug("connected") self.send_raw_packet("ZIA++HELLO") self.send_raw_packet("ZIA++RECEIVER + *") @@ -75,15 +76,16 @@ def handle_lines(self) -> None: def handle_raw_packet(self, raw_packet: str) -> None: """Handle one raw incoming packet.""" - raise NotImplementedError() + raise NotImplementedError def send_raw_packet(self, packet: str) -> None: """Encode and put packet string onto write buffer.""" data = bytes(packet + "\n\r", "utf-8") log.debug("writing data: %s", repr(data)) - self.transport.write(data) # type: ignore + assert self.transport is not None + self.transport.write(data) - def connection_lost(self, exc: Optional[Exception]) -> None: + def connection_lost(self, exc: Exception | None) -> None: """Log when connection is closed, if needed call callback.""" if exc: log.exception("disconnected due to exception") @@ -99,7 +101,7 @@ class PacketHandling(ProtocolBase): def __init__( self, *args: Any, - packet_callback: Optional[Callable[[PacketType], None]] = None, + packet_callback: Callable[[PacketType], None] | None = None, **kwargs: Any, ) -> None: """Add packethandling specific initialization. @@ -117,7 +119,7 @@ def handle_raw_packet(self, raw_packet: str) -> None: packets = [] try: packets = decode_packet(raw_packet) - except BaseException: + except RfPlayerException: log.exception("failed to parse packet data: %s", raw_packet) if packets: @@ -138,11 +140,11 @@ def handle_packet(self, packet: PacketType) -> None: # forward to callback self.packet_callback(packet) else: - print("packet", packet) + log.debug("packet %s", packet) def handle_response_packet(self, packet: PacketType) -> None: """Handle response packet.""" - raise NotImplementedError() + raise NotImplementedError def send_packet(self, fields: PacketType) -> None: """Concat fields and send packet to gateway.""" @@ -153,29 +155,25 @@ def send_command( self, protocol: str, command: str, - device_address: str = None, - device_id: str = None, + device_address: str | None = None, + device_id: str | None = None, ) -> None: """Send device command to rfplayer gateway.""" if device_id is not None: - if protocol == "EDISIOFRAME" : - self.send_raw_packet(f"ZIA++{protocol} {device_id}") - else : - self.send_raw_packet(f"ZIA++{command} {protocol} ID {device_id}") + self.send_raw_packet(f"ZIA++{command} {protocol} ID {device_id}") elif device_address is not None: self.send_raw_packet(f"ZIA++{command} {protocol} {device_address}") - elif protocol == "EDISIOFRAME": - self.send_raw_packet(f"ZIA++{command}") else: self.send_raw_packet(f"ZIA++{protocol} {command}") + class CommandSerialization(PacketHandling): """Logic for ensuring asynchronous commands are sent in order.""" def __init__( self, *args: Any, - packet_callback: Optional[Callable[[PacketType], None]] = None, + packet_callback: Callable[[PacketType], None] | None = None, **kwargs: Any, ) -> None: """Add packethandling specific initialization.""" @@ -185,6 +183,7 @@ def __init__( self.packet_callback = packet_callback self._event = asyncio.Event() self._lock = asyncio.Lock() + self._last_ack: PacketType | None = None def handle_response_packet(self, packet: PacketType) -> None: """Handle response packet.""" @@ -196,8 +195,8 @@ async def send_command_ack( self, protocol: str, command: str, - device_address: str = None, - device_id: str = None, + device_address: str | None = None, + device_id: str | None = None, ) -> bool: """Send command, wait for gateway to repond.""" async with self._lock: @@ -220,8 +219,8 @@ class EventHandling(PacketHandling): def __init__( self, *args: Any, - event_callback: Optional[Callable[[PacketType], None]] = None, - ignore: Optional[Sequence[str]] = None, + event_callback: Callable[[PacketType], None] | None = None, + ignore: Sequence[str] | None = None, **kwargs: Any, ) -> None: """Add eventhandling specific initialization.""" @@ -266,7 +265,7 @@ def handle_event(self, event: PacketType) -> None: if event.get("unit"): string += " {unit}" - print(string.format(**event)) + log.debug(string.format(**event)) def handle_packet(self, packet: PacketType) -> None: """Apply event specific handling and pass on to packet handling.""" @@ -276,32 +275,29 @@ def handle_packet(self, packet: PacketType) -> None: def ignore_event(self, event_id: str) -> bool: """Verify event id against list of events to ignore.""" log.debug("ignore_event") - for ignore in self.ignore: - if fnmatchcase(event_id, ignore): - return True - return False + return any(fnmatchcase(event_id, ignore) for ignore in self.ignore) class RfplayerProtocol(CommandSerialization, EventHandling): """Combine preferred abstractions that form complete Rflink interface.""" +# pylint: disable-next=too-many-arguments def create_rfplayer_connection( port: str, baud: int = 115200, - protocol: Type[ProtocolBase] = RfplayerProtocol, - packet_callback: Optional[Callable[[PacketType], None]] = None, - event_callback: Optional[Callable[[PacketType], None]] = None, - disconnect_callback: Optional[Callable[[Optional[Exception]], None]] = None, - ignore: Optional[Sequence[str]] = None, - loop: Optional[asyncio.AbstractEventLoop] = None, -) -> "Coroutine[Any, Any, Tuple[asyncio.BaseTransport, ProtocolBase]]": + packet_callback: Callable[[PacketType], None] | None = None, + event_callback: Callable[[PacketType], None] | None = None, + disconnect_callback: Callable[[Exception | None], None] | None = None, + ignore: Sequence[str] | None = None, + loop: asyncio.AbstractEventLoop | None = None, +) -> "Coroutine[Any, Any, tuple[asyncio.BaseTransport, ProtocolBase]]": """Create Rflink manager class, returns transport coroutine.""" if loop is None: loop = asyncio.get_event_loop() # use default protocol if not specified protocol_factory = partial( - protocol, + RfplayerProtocol, loop=loop, packet_callback=packet_callback, event_callback=event_callback, diff --git a/custom_components/rfplayer/sensor.py b/custom_components/rfplayer/sensor.py index c035f42..9cfcd74 100644 --- a/custom_components/rfplayer/sensor.py +++ b/custom_components/rfplayer/sensor.py @@ -1,8 +1,13 @@ """Support for Rfplayer sensors.""" + import logging +from typing import Any +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICES -from homeassistant.helpers.entity import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.components.sensor import RestoreSensor +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RfplayerDevice from .const import ( @@ -14,35 +19,17 @@ EVENT_KEY_SENSOR, EVENT_KEY_UNIT, ) -from .rflib.rfpparser import PACKET_FIELDS, UNITS _LOGGER = logging.getLogger(__name__) -SENSOR_ICONS = { - "humidity": "mdi:water-percent", - "battery": "mdi:battery", - "temperature": "mdi:thermometer", -} - - -def lookup_unit_for_sensor_type(sensor_type): - """Get unit for sensor type. - - Async friendly. - """ - field_abbrev = {v: k for k, v in PACKET_FIELDS.items()} - - return UNITS.get(field_abbrev.get(sensor_type)) - -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Rfplayer platform.""" config = entry.data options = entry.options - # add jamming entity - async_add_entities([RfplayerJammingSensor()]) - async def add_new_device(device_info): """Check if device is known, otherwise create device entity.""" device_id = device_info[EVENT_KEY_ID] @@ -58,36 +45,40 @@ async def add_new_device(device_info): async_add_entities([device]) if CONF_DEVICES in config: - for device_id, device_info in config[CONF_DEVICES].items(): - if EVENT_KEY_SENSOR in device_info: - await add_new_device(device_info) + for device in config[CONF_DEVICES].values(): + if EVENT_KEY_SENSOR in device: + await add_new_device(device) if options.get(CONF_AUTOMATIC_ADD, config[CONF_AUTOMATIC_ADD]): hass.data[DOMAIN][DATA_DEVICE_REGISTER][EVENT_KEY_SENSOR] = add_new_device -class RfplayerSensor(RfplayerDevice): +class RfplayerSensor(RfplayerDevice, RestoreSensor): """Representation of a Rfplayer sensor.""" + _attr_native_value: float | None = None + + # pylint: disable-next=too-many-arguments def __init__( self, - protocol, - device_id=None, - unit_of_measurement=None, - initial_event=None, - name=None, - **kwargs, - ): + protocol: str, + device_id: str | None = None, + initial_event: dict[str, Any] | None = None, + name: str | None = None, + unique_id: str | None = None, + unit_of_measurement: str | None = None, + ) -> None: """Handle sensor specific args and super init.""" - self._protocol = protocol - self._device_id = device_id - self._attr_name = name - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement super().__init__( - protocol, device_id=device_id, initial_event=initial_event, name=name + protocol=protocol, + device_id=device_id, + initial_event=initial_event, + name=name, + unique_id=unique_id, ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register update callback.""" # Register id and aliases await super().async_added_to_hass() @@ -97,31 +88,6 @@ async def async_added_to_hass(self): self._initial_event[EVENT_KEY_ID] ] = self.entity_id - def _handle_event(self, event): - """Domain specific event handler.""" - self._state = event["value"] - - @property - def state(self): - """Return value.""" - return self._state - - -class RfplayerJammingSensor(RfplayerDevice): - """Representation of a Jamming Rfplayer sensor.""" - - def __init__(self): - """Handle sensor specific args and super init.""" - self._attr_entity_category = EntityCategory.DIAGNOSTIC - super().__init__( - "JAMMING", device_id=0, name="Jamming detection" - ) - - def _handle_event(self, event): + def _handle_event(self, event: dict[str, Any]) -> None: """Domain specific event handler.""" - self._state = event["value"] - - @property - def state(self): - """Return value.""" - return self._state + self._attr_native_value = float(event["value"]) diff --git a/custom_components/rfplayer/services.yaml b/custom_components/rfplayer/services.yaml index 030e65f..677c92d 100644 --- a/custom_components/rfplayer/services.yaml +++ b/custom_components/rfplayer/services.yaml @@ -1,38 +1,26 @@ send_command: - name: Send command - description: Send device command through RFPlayer and create switch entity if needed. fields: command: - name: Command - description: The command to be sent. required: true example: "ON" selector: text: protocol: - name: Protocol - description: RFPlayer compatible protocol. required: true example: CHACON selector: text: device_address: - name: Device address - description: Device address. required: false example: A1 selector: text: device_id: - name: Device ID - description: Device ID. required: false example: 40 selector: text: automatic_add: - name: Create entity - description: Create associated switch entity required: true default: false selector: diff --git a/custom_components/rfplayer/strings.json b/custom_components/rfplayer/strings.json index 5ed0bea..dac808f 100644 --- a/custom_components/rfplayer/strings.json +++ b/custom_components/rfplayer/strings.json @@ -2,22 +2,25 @@ "config": { "step": { "user": { - "title": "GCE RFPlayer", "description": "Configure RFPlayer integration", "data": { - "device": "RFPlayer USB device", + "device": "USB devices found", + "manual_device": "USB manual path", "automatic_add": "Add device automatically when signal received", "reconnect_interval": "Reconnect interval" } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "device_missing": "A device must be specified from list or manually." }, "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "no_devices_found": "No USB device found." } }, "options": { @@ -29,5 +32,33 @@ } } } + }, + "services": { + "send_command": { + "name": "Send command", + "description": "Send device command through RFPlayer and facultativly add it as an entity.", + "fields": { + "command": { + "name": "Command", + "description": "The command to be sent." + }, + "protocol": { + "name": "Protocol", + "description": "RFPlayer compatible protocol." + }, + "device_address": { + "name": "Device address", + "description": "Exclusive with device ID (eg A1)." + }, + "device_id": { + "name": "Device ID", + "description": "Exclusive with device address (eg 1)." + }, + "automatic_add": { + "name": "Create entity", + "description": "Create associated switch entity." + } + } + } } } diff --git a/custom_components/rfplayer/switch.py b/custom_components/rfplayer/switch.py index 61461a7..da7480c 100644 --- a/custom_components/rfplayer/switch.py +++ b/custom_components/rfplayer/switch.py @@ -1,14 +1,19 @@ """Support for Rfplayer switch.""" + import logging +from typing import Any from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_DEVICES, CONF_PROTOCOL -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DATA_DEVICE_REGISTER, EVENT_KEY_COMMAND, RfplayerDevice from .const import ( COMMAND_OFF, COMMAND_ON, + CONF_AUTOMATIC_ADD, CONF_DEVICE_ADDRESS, DATA_ENTITY_LOOKUP, DOMAIN, @@ -18,7 +23,9 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Rfplayer platform.""" config = entry.data options = entry.options @@ -36,23 +43,38 @@ async def add_new_device(device_info): async_add_entities([device]) if CONF_DEVICES in config: - for device_id, device_info in config[CONF_DEVICES].items(): - if EVENT_KEY_COMMAND in device_info: - await add_new_device(device_info) + for device in config[CONF_DEVICES].values(): + if EVENT_KEY_COMMAND in device: + await add_new_device(device) - hass.data[DOMAIN][DATA_DEVICE_REGISTER][EVENT_KEY_COMMAND] = add_new_device + if options.get(CONF_AUTOMATIC_ADD, config[CONF_AUTOMATIC_ADD]): + hass.data[DOMAIN][DATA_DEVICE_REGISTER][EVENT_KEY_COMMAND] = add_new_device class RfplayerSwitch(RfplayerDevice, SwitchEntity): """Representation of a Rfplayer sensor.""" - async def async_added_to_hass(self): + # pylint: disable-next=too-many-arguments + def __init__( + self, + protocol: str, + device_id: str | None = None, + device_address: str | None = None, + initial_event: dict[str, Any] | None = None, + name: str | None = None, + ) -> None: + """Handle switch specific args and super init.""" + self._state: bool | None = None + super().__init__(protocol, device_address, device_id, initial_event, name) + + async def async_added_to_hass(self) -> None: """Restore RFPlayer device state (ON/OFF).""" await super().async_added_to_hass() - self.hass.data[DOMAIN][DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][ - self._initial_event[EVENT_KEY_ID] - ] = self.entity_id + if self._initial_event: + self.hass.data[DOMAIN][DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][ + self._initial_event[EVENT_KEY_ID] + ] = self.entity_id if self._event is None: old_state = await self.async_get_last_state() @@ -68,17 +90,17 @@ def _handle_event(self, event): self._state = False @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._state - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._async_send_command(COMMAND_ON) self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._async_send_command(COMMAND_OFF) self._state = False diff --git a/custom_components/rfplayer/translations/en.json b/custom_components/rfplayer/translations/en.json index 8e64975..1acb516 100644 --- a/custom_components/rfplayer/translations/en.json +++ b/custom_components/rfplayer/translations/en.json @@ -1,33 +1,65 @@ { - "config": { - "step": { - "user": { - "title": "GCE RFPlayer", - "description": "Configure RFPlayer integration", - "data": { - "device": "RFPlayer USB device", - "automatic_add": "Add device automatically when signal received", - "reconnect_interval": "Reconnect interval" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - } + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No USB device found.", + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "unknown": "Unexpected error" }, - "options": { - "step": { - "init": { - "title": "GCE RFPlayer Options", - "data": { - "automatic_add": "Add device automatically when signal received" - } - } + "error": { + "cannot_connect": "Failed to connect", + "device_missing": "A device must be specified from list or manually.", + "no_devices_found": "No USB device found." + }, + "step": { + "user": { + "data": { + "device": "USB devices found", + "manual_device": "USB manual path", + "automatic_add": "Add device automatically when signal received", + "reconnect_interval": "Reconnect interval" + }, + "description": "Configure RFPlayer integration" } } + }, + "options": { + "step": { + "init": { + "data": { + "automatic_add": "Add device automatically when signal received" + }, + "title": "GCE RFPlayer Options" + } + } + }, + "services": { + "send_command": { + "description": "Send device command through RFPlayer and facultativly add it as an entity.", + "fields": { + "automatic_add": { + "description": "Create associated switch entity.", + "name": "Create entity" + }, + "command": { + "description": "The command to be sent.", + "name": "Command" + }, + "device_address": { + "description": "Exclusive with device ID (eg 1).", + "name": "Device address" + }, + "device_id": { + "description": "Exclusive with device address (eg A1).", + "name": "Device ID" + }, + "protocol": { + "description": "RFPlayer compatible protocol.", + "name": "Protocol" + } + }, + "name": "Send command" + } } +} diff --git a/custom_components/rfplayer/translations/fr.json b/custom_components/rfplayer/translations/fr.json index 9deed62..a9715b7 100644 --- a/custom_components/rfplayer/translations/fr.json +++ b/custom_components/rfplayer/translations/fr.json @@ -1,33 +1,65 @@ { - "config": { - "step": { - "user": { - "title": "GCE RFPlayer", - "description": "Configurer l'intégration RFPlayer", - "data": { - "device": "Appareil USB RFPlayer", - "automatic_add": "Ajouter les appareil automatiquement lorsqu'un signal est reçu", - "reconnect_interval": "Interval de reconnexion" - } + "config": { + "step": { + "user": { + "title": "GCE RFPlayer", + "description": "Configurer l'intégration RFPlayer", + "data": { + "device": "Appareils USB trouvés", + "manual_device": "Chemin USB manuel", + "automatic_add": "Ajouter les appareil automatiquement lorsqu'un signal est reçu", + "reconnect_interval": "Interval de reconnexion" } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "options": { - "step": { - "init": { - "title": "Options GCE RFPlayer", - "data": { - "automatic_add":"Ajouter les appareil automatiquement lorsqu'un signal est reçu" - } + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "device_missing": "Un appareil USB doit être spécifié manuellement ou depuis la liste." + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "no_devices_found": "Aucun appareil USB n'a été trouvé." + } + }, + "options": { + "step": { + "init": { + "title": "Options GCE RFPlayer", + "data": { + "automatic_add": "Ajouter les appareil automatiquement lorsqu'un signal est reçu" + } + } + } + }, + "services": { + "send_command": { + "name": "Envoyer une commande", + "description": "Envoyer une commande sur un appareil à travers RFPlayer et facultativement l'ajouter en tant qu'entité.", + "fields": { + "command": { + "name": "Commande", + "description": "La commande à envoyer." + }, + "protocol": { + "name": "Protocol", + "description": "Protocole compatible RFPlayer." + }, + "device_address": { + "name": "Adresse de l'appareil", + "description": "Exclusif avec l'ID (exemple A1)." + }, + "device_id": { + "name": "ID de l'appareil", + "description": "Exclusif avec l'adresse (exemple 1)." + }, + "automatic_add": { + "name": "Créer une entité", + "description": "Créer l'entité de type switch associée." } } } } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..8024c34 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "GCE RFPlayer", + "country": "FR", + "render_readme": true +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f9dda21 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,124 @@ +[project] +name = "HA_RFPlayer" +version = "0.0.0" +license = { text = "MIT License" } +description = "Home-Assistant GCE RFPlayer" +authors = [{ name = "Aohzan", email = "aohzan@gmail.com"}] +maintainers = [{ name = "Aohzan", email = "aohzan@gmail.com"}] +readme = "README.md" +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3", +] +keywords = [ + "custom component", + "custom integration", + "gce", + "hacs-component", + "hacs-integration", + "hacs-repository", + "hacs", + "hass.io", + "hass", + "hassio", + "home assistant", + "home-assistant", + "homeassistant", + "integration", + "rfplayer", + "utils", +] +requires-python = ">=3.12,<3.13" +dependencies = [ + "homeassistant>=2024.4.0", + "colorlog>=6.8.2", + "pyserial==3.5", + "pyserial-asyncio==0.6", + "aiodns==3.2.0", + "numpy>=1.26.4", +] + +[project.urls] +Homepage = "https://www.gce-electronics.com/fr/produits-radio/1777-rf-player-3770008041004.html" +Documentation = "https://github.com/gce-electronics/HA_RFPlayer/blob/main/rfplayer_api_v1.15.pdf" +Changelog = "https://github.com/gce-electronics/HA_RFPlayer/releases" +Support = "https://forum.gce-electronics.com/" +Issues = "https://github.com/gce-electronics/HA_RFPlayer/issues" +Repository = "https://github.com/gce-electronics/HA_RFPlayer" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["custom_components/rfplayer"] + +[tool.ruff] +src = ["custom_components/rfplayer"] + +[tool.ruff.lint] +ignore = [ + "ANN101", # Self... explanatory + "ANN401", # Opiniated warning on disallowing dynamically typed expressions + "D203", # Conflicts with other rules + "D213", # Conflicts with other rules + "TID252", # Relative imports + "RUF012", # Just broken + + # Formatter conflicts + "COM812", + "COM819", + "D206", + "E501", + "ISC001", + "Q000", + "Q001", + "Q002", + "Q003", + "W191", +] +select = ["ALL"] + +[tool.ruff.lint.flake8-import-conventions.extend-aliases] +"homeassistant.helpers.area_registry" = "ar" +"homeassistant.helpers.config_validation" = "cv" +"homeassistant.helpers.device_registry" = "dr" +"homeassistant.helpers.entity_registry" = "er" +"homeassistant.helpers.issue_registry" = "ir" +voluptuous = "vol" + +[tool.ruff.lint.isort] +force-sort-within-sections = true +known-first-party = [ + "homeassistant", +] +combine-as-imports = true + +[tool.pylint."MESSAGES CONTROL"] +# Reasons disabled: +# format - handled by ruff +# duplicate-code - unavoidable +# used-before-assignment - false positives with TYPE_CHECKING structures +disable = [ + "abstract-method", + "duplicate-code", + "format", + "unexpected-keyword-arg", + "used-before-assignment", +] + +[tool.rye] +managed = true +dev-dependencies = [ + "pre-commit>=3.6.0", + "pre-commit-hooks>=4.5.0", + "pylint>=3.1.0", + "ruff>=0.3.2", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..97863fc --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,268 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false + +-e file:. +acme==2.9.0 + # via hass-nabucasa +aiodns==3.2.0 + # via ha-rfplayer +aiohttp==3.9.3 + # via aiohttp-cors + # via aiohttp-fast-url-dispatcher + # via aiohttp-zlib-ng + # via hass-nabucasa + # via homeassistant +aiohttp-cors==0.7.0 + # via homeassistant +aiohttp-fast-url-dispatcher==0.3.0 + # via homeassistant +aiohttp-zlib-ng==0.3.1 + # via homeassistant +aiooui==0.1.5 + # via bluetooth-adapters +aiosignal==1.3.1 + # via aiohttp +anyio==4.3.0 + # via httpx +astral==2.2 + # via homeassistant +astroid==3.1.0 + # via pylint +async-interrupt==1.1.1 + # via homeassistant +async-timeout==4.0.3 + # via snitun +atomicwrites-homeassistant==1.4.1 + # via hass-nabucasa + # via homeassistant +attrs==23.2.0 + # via aiohttp + # via hass-nabucasa + # via homeassistant + # via snitun +awesomeversion==24.2.0 + # via homeassistant +bcrypt==4.1.2 + # via homeassistant +bleak==0.21.1 + # via bleak-retry-connector + # via bluetooth-adapters + # via habluetooth +bleak-retry-connector==3.4.0 + # via habluetooth +bluetooth-adapters==0.18.0 + # via bleak-retry-connector + # via bluetooth-auto-recovery + # via habluetooth +bluetooth-auto-recovery==1.4.0 + # via habluetooth +bluetooth-data-tools==1.19.0 + # via habluetooth +boto3==1.34.77 + # via pycognito +botocore==1.34.77 + # via boto3 + # via s3transfer +btsocket==0.2.0 + # via bluetooth-auto-recovery +certifi==2024.2.2 + # via homeassistant + # via httpcore + # via httpx + # via requests +cffi==1.16.0 + # via cryptography + # via pycares +cfgv==3.4.0 + # via pre-commit +charset-normalizer==3.3.2 + # via requests +ciso8601==2.3.1 + # via hass-nabucasa + # via homeassistant +colorlog==6.8.2 + # via ha-rfplayer +cryptography==42.0.5 + # via acme + # via bluetooth-data-tools + # via hass-nabucasa + # via homeassistant + # via josepy + # via pyjwt + # via pyopenssl + # via snitun +dbus-fast==2.21.1 + # via bleak + # via bleak-retry-connector + # via bluetooth-adapters +dill==0.3.8 + # via pylint +distlib==0.3.8 + # via virtualenv +envs==1.4 + # via pycognito +filelock==3.13.3 + # via virtualenv +fnv-hash-fast==0.5.0 + # via homeassistant +fnvhash==0.1.0 + # via fnv-hash-fast +frozenlist==1.4.1 + # via aiohttp + # via aiosignal +greenlet==3.0.3 + # via sqlalchemy +h11==0.14.0 + # via httpcore +habluetooth==2.4.2 + # via home-assistant-bluetooth +hass-nabucasa==0.79.0 + # via homeassistant +home-assistant-bluetooth==1.12.0 + # via homeassistant +homeassistant==2024.4.0 + # via ha-rfplayer +httpcore==1.0.5 + # via httpx +httpx==0.27.0 + # via homeassistant +identify==2.5.35 + # via pre-commit +idna==3.6 + # via anyio + # via httpx + # via requests + # via yarl +ifaddr==0.2.0 + # via homeassistant +isort==5.13.2 + # via pylint +jinja2==3.1.3 + # via homeassistant +jmespath==1.0.1 + # via boto3 + # via botocore +josepy==1.14.0 + # via acme +lru-dict==1.3.0 + # via homeassistant +markupsafe==2.1.5 + # via jinja2 +mccabe==0.7.0 + # via pylint +multidict==6.0.5 + # via aiohttp + # via yarl +nodeenv==1.8.0 + # via pre-commit +numpy==1.26.4 + # via ha-rfplayer +orjson==3.9.15 + # via homeassistant +packaging==24.0 + # via homeassistant +pillow==10.2.0 + # via homeassistant +pip==24.0 + # via homeassistant +platformdirs==4.2.0 + # via pylint + # via virtualenv +pre-commit==3.7.0 +pre-commit-hooks==4.5.0 +psutil==5.9.8 + # via psutil-home-assistant +psutil-home-assistant==0.0.1 + # via homeassistant +pycares==4.4.0 + # via aiodns +pycognito==2024.2.0 + # via hass-nabucasa +pycparser==2.22 + # via cffi +pyjwt==2.8.0 + # via hass-nabucasa + # via homeassistant + # via pycognito +pylint==3.1.0 +pyopenssl==24.1.0 + # via acme + # via homeassistant + # via josepy +pyrfc3339==1.1 + # via acme +pyric==0.1.6.3 + # via bluetooth-auto-recovery +pyserial==3.5 + # via ha-rfplayer + # via pyserial-asyncio +pyserial-asyncio==0.6 + # via ha-rfplayer +python-dateutil==2.9.0.post0 + # via botocore +python-slugify==8.0.4 + # via homeassistant +pytz==2024.1 + # via acme + # via astral + # via pyrfc3339 +pyyaml==6.0.1 + # via homeassistant + # via pre-commit +requests==2.31.0 + # via acme + # via homeassistant + # via pycognito +ruamel-yaml==0.18.6 + # via pre-commit-hooks +ruamel-yaml-clib==0.2.8 + # via ruamel-yaml +ruff==0.3.5 +s3transfer==0.10.1 + # via boto3 +setuptools==69.2.0 + # via acme + # via nodeenv +six==1.16.0 + # via python-dateutil +sniffio==1.3.1 + # via anyio + # via httpx +snitun==0.36.2 + # via hass-nabucasa +sqlalchemy==2.0.29 + # via homeassistant +text-unidecode==1.3 + # via python-slugify +tomlkit==0.12.4 + # via pylint +typing-extensions==4.10.0 + # via homeassistant + # via sqlalchemy +ulid-transform==0.9.0 + # via homeassistant +urllib3==1.26.18 + # via botocore + # via homeassistant + # via requests +usb-devices==0.4.5 + # via bluetooth-adapters + # via bluetooth-auto-recovery +virtualenv==20.25.1 + # via pre-commit +voluptuous==0.13.1 + # via homeassistant + # via voluptuous-serialize +voluptuous-serialize==2.6.0 + # via homeassistant +yarl==1.9.4 + # via aiohttp + # via homeassistant +zlib-ng==0.4.1 + # via aiohttp-zlib-ng diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..611ad84 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,233 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false + +-e file:. +acme==2.9.0 + # via hass-nabucasa +aiodns==3.2.0 + # via ha-rfplayer +aiohttp==3.9.3 + # via aiohttp-cors + # via aiohttp-fast-url-dispatcher + # via aiohttp-zlib-ng + # via hass-nabucasa + # via homeassistant +aiohttp-cors==0.7.0 + # via homeassistant +aiohttp-fast-url-dispatcher==0.3.0 + # via homeassistant +aiohttp-zlib-ng==0.3.1 + # via homeassistant +aiooui==0.1.5 + # via bluetooth-adapters +aiosignal==1.3.1 + # via aiohttp +anyio==4.3.0 + # via httpx +astral==2.2 + # via homeassistant +async-interrupt==1.1.1 + # via homeassistant +async-timeout==4.0.3 + # via snitun +atomicwrites-homeassistant==1.4.1 + # via hass-nabucasa + # via homeassistant +attrs==23.2.0 + # via aiohttp + # via hass-nabucasa + # via homeassistant + # via snitun +awesomeversion==24.2.0 + # via homeassistant +bcrypt==4.1.2 + # via homeassistant +bleak==0.21.1 + # via bleak-retry-connector + # via bluetooth-adapters + # via habluetooth +bleak-retry-connector==3.4.0 + # via habluetooth +bluetooth-adapters==0.18.0 + # via bleak-retry-connector + # via bluetooth-auto-recovery + # via habluetooth +bluetooth-auto-recovery==1.4.0 + # via habluetooth +bluetooth-data-tools==1.19.0 + # via habluetooth +boto3==1.34.77 + # via pycognito +botocore==1.34.77 + # via boto3 + # via s3transfer +btsocket==0.2.0 + # via bluetooth-auto-recovery +certifi==2024.2.2 + # via homeassistant + # via httpcore + # via httpx + # via requests +cffi==1.16.0 + # via cryptography + # via pycares +charset-normalizer==3.3.2 + # via requests +ciso8601==2.3.1 + # via hass-nabucasa + # via homeassistant +colorlog==6.8.2 + # via ha-rfplayer +cryptography==42.0.5 + # via acme + # via bluetooth-data-tools + # via hass-nabucasa + # via homeassistant + # via josepy + # via pyjwt + # via pyopenssl + # via snitun +dbus-fast==2.21.1 + # via bleak + # via bleak-retry-connector + # via bluetooth-adapters +envs==1.4 + # via pycognito +fnv-hash-fast==0.5.0 + # via homeassistant +fnvhash==0.1.0 + # via fnv-hash-fast +frozenlist==1.4.1 + # via aiohttp + # via aiosignal +greenlet==3.0.3 + # via sqlalchemy +h11==0.14.0 + # via httpcore +habluetooth==2.4.2 + # via home-assistant-bluetooth +hass-nabucasa==0.79.0 + # via homeassistant +home-assistant-bluetooth==1.12.0 + # via homeassistant +homeassistant==2024.4.0 + # via ha-rfplayer +httpcore==1.0.5 + # via httpx +httpx==0.27.0 + # via homeassistant +idna==3.6 + # via anyio + # via httpx + # via requests + # via yarl +ifaddr==0.2.0 + # via homeassistant +jinja2==3.1.3 + # via homeassistant +jmespath==1.0.1 + # via boto3 + # via botocore +josepy==1.14.0 + # via acme +lru-dict==1.3.0 + # via homeassistant +markupsafe==2.1.5 + # via jinja2 +multidict==6.0.5 + # via aiohttp + # via yarl +numpy==1.26.4 + # via ha-rfplayer +orjson==3.9.15 + # via homeassistant +packaging==24.0 + # via homeassistant +pillow==10.2.0 + # via homeassistant +pip==24.0 + # via homeassistant +psutil==5.9.8 + # via psutil-home-assistant +psutil-home-assistant==0.0.1 + # via homeassistant +pycares==4.4.0 + # via aiodns +pycognito==2024.2.0 + # via hass-nabucasa +pycparser==2.22 + # via cffi +pyjwt==2.8.0 + # via hass-nabucasa + # via homeassistant + # via pycognito +pyopenssl==24.1.0 + # via acme + # via homeassistant + # via josepy +pyrfc3339==1.1 + # via acme +pyric==0.1.6.3 + # via bluetooth-auto-recovery +pyserial==3.5 + # via ha-rfplayer + # via pyserial-asyncio +pyserial-asyncio==0.6 + # via ha-rfplayer +python-dateutil==2.9.0.post0 + # via botocore +python-slugify==8.0.4 + # via homeassistant +pytz==2024.1 + # via acme + # via astral + # via pyrfc3339 +pyyaml==6.0.1 + # via homeassistant +requests==2.31.0 + # via acme + # via homeassistant + # via pycognito +s3transfer==0.10.1 + # via boto3 +setuptools==69.2.0 + # via acme +six==1.16.0 + # via python-dateutil +sniffio==1.3.1 + # via anyio + # via httpx +snitun==0.36.2 + # via hass-nabucasa +sqlalchemy==2.0.29 + # via homeassistant +text-unidecode==1.3 + # via python-slugify +typing-extensions==4.10.0 + # via homeassistant + # via sqlalchemy +ulid-transform==0.9.0 + # via homeassistant +urllib3==1.26.18 + # via botocore + # via homeassistant + # via requests +usb-devices==0.4.5 + # via bluetooth-adapters + # via bluetooth-auto-recovery +voluptuous==0.13.1 + # via homeassistant + # via voluptuous-serialize +voluptuous-serialize==2.6.0 + # via homeassistant +yarl==1.9.4 + # via aiohttp + # via homeassistant +zlib-ng==0.4.1 + # via aiohttp-zlib-ng diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..caa0a6f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +colorlog==6.8.2 +homeassistant==2024.4.0 +ruff==0.3.5 + +# pyserial==3.5 +# pyserial-asyncio==0.6