mirror of
https://github.com/home-assistant/core.git
synced 2026-03-09 15:44:00 +01:00
Compare commits
6 Commits
number/add
...
python-3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09546525b6 | ||
|
|
37a59bd23c | ||
|
|
6793a98c08 | ||
|
|
0ee4589ba3 | ||
|
|
55bd4b00b4 | ||
|
|
43f5f922e3 |
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: github-pr-reviewer
|
||||
description: Review a GitHub pull request and provide feedback comments. Use when the user says "review the current PR" or asks to review a specific PR.
|
||||
---
|
||||
|
||||
# Review GitHub Pull Request
|
||||
|
||||
## Preparation:
|
||||
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
|
||||
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
|
||||
- Do NOT attempt any workarounds.
|
||||
- Do NOT proceed with the review.
|
||||
- ALERT about the failure and WAIT for instructions.
|
||||
- This is a hard requirement - no exceptions.
|
||||
|
||||
## Follow these steps:
|
||||
1. Use 'gh pr view' to get the PR details and description.
|
||||
2. Use 'gh pr diff' to see all the changes in the PR.
|
||||
3. Analyze the code changes for:
|
||||
- Code quality and style consistency
|
||||
- Potential bugs or issues
|
||||
- Performance implications
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
- Documentation updates if needed
|
||||
4. Ensure any existing review comments have been addressed.
|
||||
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
|
||||
|
||||
## IMPORTANT:
|
||||
- Just review. DO NOT make any changes
|
||||
- Be constructive and specific in your comments
|
||||
- Suggest improvements where appropriate
|
||||
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
|
||||
- No need to run tests or linters, just review the code changes.
|
||||
- No need to highlight things that are already good.
|
||||
|
||||
## Output format:
|
||||
- List specific comments for each file/line that needs attention
|
||||
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
|
||||
- Example output:
|
||||
```
|
||||
Overall assessment: request changes.
|
||||
- [CRITICAL] Memory leak in homeassistant/components/sensor/my_sensor.py:143
|
||||
- [PROBLEM] Inefficient algorithm in homeassistant/helpers/data_processing.py:87
|
||||
- [SUGGESTION] Improve variable naming in homeassistant/helpers/config_validation.py:45
|
||||
```
|
||||
1180
.github/copilot-instructions.md
vendored
1180
.github/copilot-instructions.md
vendored
File diff suppressed because it is too large
Load Diff
27
.github/workflows/builder.yml
vendored
27
.github/workflows/builder.yml
vendored
@@ -10,11 +10,12 @@ on:
|
||||
|
||||
env:
|
||||
BUILD_TYPE: core
|
||||
DEFAULT_PYTHON: "3.14.3"
|
||||
PIP_TIMEOUT: 60
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2026.01.0"
|
||||
BASE_IMAGE_VERSION: "2026.02.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
@@ -41,10 +42,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Get information
|
||||
id: info
|
||||
@@ -79,7 +80,7 @@ jobs:
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
@@ -111,7 +112,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -122,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
@@ -131,11 +132,11 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
name: package
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Adjust nightly version
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -181,7 +182,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -537,13 +538,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -614,7 +615,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
90
.github/workflows/ci.yaml
vendored
90
.github/workflows/ci.yaml
vendored
@@ -41,7 +41,8 @@ env:
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.4"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
DEFAULT_PYTHON: "3.14.3"
|
||||
ALL_PYTHON_VERSIONS: "['3.14.3']"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
# 10.6 is the current long-term-support
|
||||
@@ -165,11 +166,6 @@ jobs:
|
||||
tests_glob=""
|
||||
lint_only=""
|
||||
skip_coverage=""
|
||||
default_python=$(cat .python-version)
|
||||
all_python_versions=$(jq -cn \
|
||||
--arg default_python "${default_python}" \
|
||||
--argjson additional_python_versions "${ADDITIONAL_PYTHON_VERSIONS}" \
|
||||
'[$default_python] + $additional_python_versions')
|
||||
|
||||
if [[ "${INTEGRATION_CHANGES}" != "[]" ]];
|
||||
then
|
||||
@@ -239,8 +235,8 @@ jobs:
|
||||
echo "mariadb_groups=${mariadb_groups}" >> $GITHUB_OUTPUT
|
||||
echo "postgresql_groups: ${postgresql_groups}"
|
||||
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
|
||||
echo "python_versions: ${all_python_versions}"
|
||||
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
|
||||
echo "python_versions: ${ALL_PYTHON_VERSIONS}"
|
||||
echo "python_versions=${ALL_PYTHON_VERSIONS}" >> $GITHUB_OUTPUT
|
||||
echo "test_full_suite: ${test_full_suite}"
|
||||
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
|
||||
echo "integrations_glob: ${integrations_glob}"
|
||||
@@ -456,7 +452,7 @@ jobs:
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
- name: Upload pip_freeze artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: pip-freeze-${{ matrix.python-version }}
|
||||
path: pip_freeze.txt
|
||||
@@ -507,13 +503,13 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
@@ -544,13 +540,13 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
@@ -580,11 +576,11 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
- name: Run gen_copilot_instructions.py
|
||||
run: |
|
||||
@@ -657,7 +653,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
python -m script.licenses extract --output-file=licenses-${PYTHON_VERSION}.json
|
||||
- name: Upload licenses
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
|
||||
path: licenses-${{ matrix.python-version }}.json
|
||||
@@ -686,13 +682,13 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
@@ -739,13 +735,13 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
@@ -790,11 +786,11 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
- name: Generate partial mypy restore key
|
||||
id: generate-mypy-key
|
||||
@@ -802,7 +798,7 @@ jobs:
|
||||
mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3)
|
||||
echo "version=${mypy_version}" >> $GITHUB_OUTPUT
|
||||
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore full Python virtual environment
|
||||
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
@@ -883,13 +879,13 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
@@ -905,7 +901,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
python -m script.split_tests ${TEST_GROUP_COUNT} tests
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: pytest_buckets
|
||||
path: pytest_buckets.txt
|
||||
@@ -982,7 +978,7 @@ jobs:
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: pytest_buckets
|
||||
- name: Compile English translations
|
||||
@@ -1024,14 +1020,14 @@ jobs:
|
||||
2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: pytest-*.txt
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
@@ -1044,7 +1040,7 @@ jobs:
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: junit.xml
|
||||
@@ -1181,7 +1177,7 @@ jobs:
|
||||
2>&1 | tee pytest-${PYTHON_VERSION}-${mariadb}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1189,7 +1185,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1203,7 +1199,7 @@ jobs:
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: test-results-mariadb-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1342,7 +1338,7 @@ jobs:
|
||||
2>&1 | tee pytest-${PYTHON_VERSION}-${postgresql}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1350,7 +1346,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1364,7 +1360,7 @@ jobs:
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: test-results-postgres-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1391,7 +1387,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -1518,14 +1514,14 @@ jobs:
|
||||
2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: pytest-*.txt
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
@@ -1538,7 +1534,7 @@ jobs:
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: junit.xml
|
||||
@@ -1562,7 +1558,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -1591,7 +1587,7 @@ jobs:
|
||||
&& needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
7
.github/workflows/translations.yml
vendored
7
.github/workflows/translations.yml
vendored
@@ -15,6 +15,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.14.3"
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
name: Upload
|
||||
@@ -26,10 +29,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Upload Translations
|
||||
env:
|
||||
|
||||
25
.github/workflows/wheels.yml
vendored
25
.github/workflows/wheels.yml
vendored
@@ -16,6 +16,9 @@ on:
|
||||
- "requirements.txt"
|
||||
- "script/gen_requirements_all.py"
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.14.3"
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
@@ -33,11 +36,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
|
||||
- name: Create Python virtual environment
|
||||
@@ -74,7 +77,7 @@ jobs:
|
||||
) > .env_file
|
||||
|
||||
- name: Upload env_file
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -82,7 +85,7 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
path: ./requirements_diff.txt
|
||||
@@ -94,7 +97,7 @@ jobs:
|
||||
python -m script.gen_requirements_all ci
|
||||
|
||||
- name: Upload requirements_all_wheels
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
path: ./requirements_all_wheels_*.txt
|
||||
@@ -121,12 +124,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
@@ -172,17 +175,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Download requirements_all_wheels
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
@@ -206,4 +209,4 @@ jobs:
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all_wheels_${{ matrix.arch }}.txt"
|
||||
requirements: "requirements_all.txt"
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.14.2
|
||||
3.14
|
||||
|
||||
@@ -123,6 +123,7 @@ homeassistant.components.blueprint.*
|
||||
homeassistant.components.bluesound.*
|
||||
homeassistant.components.bluetooth.*
|
||||
homeassistant.components.bluetooth_adapters.*
|
||||
homeassistant.components.bmw_connected_drive.*
|
||||
homeassistant.components.bond.*
|
||||
homeassistant.components.bosch_alarm.*
|
||||
homeassistant.components.braviatv.*
|
||||
|
||||
318
AGENTS.md
318
AGENTS.md
@@ -4,17 +4,325 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
## Code Review Guidelines
|
||||
|
||||
**When reviewing code, do NOT comment on:**
|
||||
- **Missing imports** - We use static analysis tooling to catch that
|
||||
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
|
||||
|
||||
**Git commit practices during review:**
|
||||
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
|
||||
|
||||
## Python Requirements
|
||||
|
||||
- **Compatibility**: Python 3.13+
|
||||
- **Language Features**: Use the newest features when possible:
|
||||
- Pattern matching
|
||||
- Type hints
|
||||
- f-strings (preferred over `%` or `.format()`)
|
||||
- Dataclasses
|
||||
- Walrus operator
|
||||
|
||||
### Strict Typing (Platinum)
|
||||
- **Comprehensive Type Hints**: Add type hints to all functions, methods, and variables
|
||||
- **Custom Config Entry Types**: When using runtime_data:
|
||||
```python
|
||||
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
|
||||
```
|
||||
- **Library Requirements**: Include `py.typed` file for PEP-561 compliance
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
- **Formatting**: Ruff
|
||||
- **Linting**: PyLint and Ruff
|
||||
- **Type Checking**: MyPy
|
||||
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
|
||||
- **Testing**: pytest with plain functions and fixtures
|
||||
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)
|
||||
|
||||
### Writing Style Guidelines
|
||||
- **Tone**: Friendly and informative
|
||||
- **Perspective**: Use second-person ("you" and "your") for user-facing messages
|
||||
- **Inclusivity**: Use objective, non-discriminatory language
|
||||
- **Clarity**: Write for non-native English speakers
|
||||
- **Formatting in Messages**:
|
||||
- Use backticks for: file paths, filenames, variable names, field entries
|
||||
- Use sentence case for titles and messages (capitalize only the first word and proper nouns)
|
||||
- Avoid abbreviations when possible
|
||||
|
||||
### Documentation Standards
|
||||
- **File Headers**: Short and concise
|
||||
```python
|
||||
"""Integration for Peblar EV chargers."""
|
||||
```
|
||||
- **Method/Function Docstrings**: Required for all
|
||||
```python
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
|
||||
"""Set up Peblar from a config entry."""
|
||||
```
|
||||
- **Comment Style**:
|
||||
- Use clear, descriptive comments
|
||||
- Explain the "why" not just the "what"
|
||||
- Keep code block lines under 80 characters when possible
|
||||
- Use progressive disclosure (simple explanation first, complex details later)
|
||||
|
||||
## Async Programming
|
||||
|
||||
- All external I/O operations must be async
|
||||
- **Best Practices**:
|
||||
- Avoid sleeping in loops
|
||||
- Avoid awaiting in loops - use `gather` instead
|
||||
- No blocking calls
|
||||
- Group executor jobs when possible - switching between event loop and executor is expensive
|
||||
|
||||
### Blocking Operations
|
||||
- **Use Executor**: For blocking I/O operations
|
||||
```python
|
||||
result = await hass.async_add_executor_job(blocking_function, args)
|
||||
```
|
||||
- **Never Block Event Loop**: Avoid file operations, `time.sleep()`, blocking HTTP calls
|
||||
- **Replace with Async**: Use `asyncio.sleep()` instead of `time.sleep()`
|
||||
|
||||
### Thread Safety
|
||||
- **@callback Decorator**: For event loop safe functions
|
||||
```python
|
||||
@callback
|
||||
def async_update_callback(self, event):
|
||||
"""Safe to run in event loop."""
|
||||
self.async_write_ha_state()
|
||||
```
|
||||
- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads
|
||||
- **Registry Changes**: Must be done in event loop thread
|
||||
|
||||
### Error Handling
|
||||
- **Exception Types**: Choose most specific exception available
|
||||
- `ServiceValidationError`: User input errors (preferred over `ValueError`)
|
||||
- `HomeAssistantError`: Device communication failures
|
||||
- `ConfigEntryNotReady`: Temporary setup issues (device offline)
|
||||
- `ConfigEntryAuthFailed`: Authentication problems
|
||||
- `ConfigEntryError`: Permanent setup issues
|
||||
- **Try/Catch Best Practices**:
|
||||
- Only wrap code that can throw exceptions
|
||||
- Keep try blocks minimal - process data after the try/catch
|
||||
- **Avoid bare exceptions** except in specific cases:
|
||||
- ❌ Generally not allowed: `except:` or `except Exception:`
|
||||
- ✅ Allowed in config flows to ensure robustness
|
||||
- ✅ Allowed in functions/methods that run in background tasks
|
||||
- Bad pattern:
|
||||
```python
|
||||
try:
|
||||
data = await device.get_data() # Can throw
|
||||
# ❌ Don't process data inside try block
|
||||
processed = data.get("value", 0) * 100
|
||||
self._attr_native_value = processed
|
||||
except DeviceError:
|
||||
_LOGGER.error("Failed to get data")
|
||||
```
|
||||
- Good pattern:
|
||||
```python
|
||||
try:
|
||||
data = await device.get_data() # Can throw
|
||||
except DeviceError:
|
||||
_LOGGER.error("Failed to get data")
|
||||
return
|
||||
|
||||
# ✅ Process data outside try block
|
||||
processed = data.get("value", 0) * 100
|
||||
self._attr_native_value = processed
|
||||
```
|
||||
- **Bare Exception Usage**:
|
||||
```python
|
||||
# ❌ Not allowed in regular code
|
||||
try:
|
||||
data = await device.get_data()
|
||||
except Exception: # Too broad
|
||||
_LOGGER.error("Failed")
|
||||
|
||||
# ✅ Allowed in config flow for robustness
|
||||
async def async_step_user(self, user_input=None):
|
||||
try:
|
||||
await self._test_connection(user_input)
|
||||
except Exception: # Allowed here
|
||||
errors["base"] = "unknown"
|
||||
|
||||
# ✅ Allowed in background tasks
|
||||
async def _background_refresh():
|
||||
try:
|
||||
await coordinator.async_refresh()
|
||||
except Exception: # Allowed in task
|
||||
_LOGGER.exception("Unexpected error in background task")
|
||||
```
|
||||
- **Setup Failure Patterns**:
|
||||
```python
|
||||
try:
|
||||
await device.async_setup()
|
||||
except (asyncio.TimeoutError, TimeoutException) as ex:
|
||||
raise ConfigEntryNotReady(f"Timeout connecting to {device.host}") from ex
|
||||
except AuthFailed as ex:
|
||||
raise ConfigEntryAuthFailed(f"Credentials expired for {device.name}") from ex
|
||||
```
|
||||
|
||||
### Logging
|
||||
- **Format Guidelines**:
|
||||
- No periods at end of messages
|
||||
- No integration names/domains (added automatically)
|
||||
- No sensitive data (keys, tokens, passwords)
|
||||
- Use debug level for non-user-facing messages
|
||||
- **Use Lazy Logging**:
|
||||
```python
|
||||
_LOGGER.debug("This is a log message with %s", variable)
|
||||
```
|
||||
|
||||
### Unavailability Logging
|
||||
- **Log Once**: When device/service becomes unavailable (info level)
|
||||
- **Log Recovery**: When device/service comes back online
|
||||
- **Implementation Pattern**:
|
||||
```python
|
||||
_unavailable_logged: bool = False
|
||||
|
||||
if not self._unavailable_logged:
|
||||
_LOGGER.info("The sensor is unavailable: %s", ex)
|
||||
self._unavailable_logged = True
|
||||
# On recovery:
|
||||
if self._unavailable_logged:
|
||||
_LOGGER.info("The sensor is back online")
|
||||
self._unavailable_logged = False
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
.vscode/tasks.json contains useful commands used for development.
|
||||
### Environment
|
||||
- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate`
|
||||
- **Dev container**: No activation needed, the environment is pre-configured
|
||||
|
||||
## Python Syntax Notes
|
||||
### Code Quality & Linting
|
||||
- **Run all linters on all files**: `prek run --all-files`
|
||||
- **Run linters on staged files only**: `prek run`
|
||||
- **PyLint on everything** (slow): `pylint homeassistant`
|
||||
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
|
||||
- **MyPy type checking (whole project)**: `mypy homeassistant/`
|
||||
- **MyPy on specific integration**: `mypy homeassistant/components/my_integration`
|
||||
|
||||
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
|
||||
### Testing
|
||||
- **Quick test of changed files**: `pytest --timeout=10 --picked`
|
||||
- **Update test snapshots**: Add `--snapshot-update` to pytest command
|
||||
- ⚠️ Omit test results after using `--snapshot-update`
|
||||
- Always run tests again without the flag to verify snapshots
|
||||
- **Full test suite** (AVOID - very slow): `pytest ./tests`
|
||||
|
||||
## Good practices
|
||||
### Dependencies & Requirements
|
||||
- **Update generated files after dependency changes**: `python -m script.gen_requirements_all`
|
||||
- **Install all Python requirements**:
|
||||
```bash
|
||||
uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt
|
||||
```
|
||||
- **Install test requirements only**:
|
||||
```bash
|
||||
uv pip install -r requirements_test_all.txt -r requirements.txt
|
||||
```
|
||||
|
||||
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
|
||||
### Translations
|
||||
- **Update translations after strings.json changes**:
|
||||
```bash
|
||||
python -m script.translations develop --all
|
||||
```
|
||||
|
||||
### Project Validation
|
||||
- **Run hassfest** (checks project structure and updates generated files):
|
||||
```bash
|
||||
python -m script.hassfest
|
||||
```
|
||||
|
||||
## Common Anti-Patterns & Best Practices
|
||||
|
||||
### ❌ **Avoid These Patterns**
|
||||
```python
|
||||
# Blocking operations in event loop
|
||||
data = requests.get(url) # ❌ Blocks event loop
|
||||
time.sleep(5) # ❌ Blocks event loop
|
||||
|
||||
# Reusing BleakClient instances
|
||||
self.client = BleakClient(address)
|
||||
await self.client.connect()
|
||||
# Later...
|
||||
await self.client.connect() # ❌ Don't reuse
|
||||
|
||||
# Hardcoded strings in code
|
||||
self._attr_name = "Temperature Sensor" # ❌ Not translatable
|
||||
|
||||
# Missing error handling
|
||||
data = await self.api.get_data() # ❌ No exception handling
|
||||
|
||||
# Storing sensitive data in diagnostics
|
||||
return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets
|
||||
|
||||
# Accessing hass.data directly in tests
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data
|
||||
|
||||
# User-configurable polling intervals
|
||||
# In config flow
|
||||
vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed
|
||||
# In coordinator
|
||||
update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed
|
||||
|
||||
# User-configurable config entry names (non-helper integrations)
|
||||
vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations
|
||||
|
||||
# Too much code in try block
|
||||
try:
|
||||
response = await client.get_data() # Can throw
|
||||
# ❌ Data processing should be outside try block
|
||||
temperature = response["temperature"] / 10
|
||||
humidity = response["humidity"]
|
||||
self._attr_native_value = temperature
|
||||
except ClientError:
|
||||
_LOGGER.error("Failed to fetch data")
|
||||
|
||||
# Bare exceptions in regular code
|
||||
try:
|
||||
value = await sensor.read_value()
|
||||
except Exception: # ❌ Too broad - catch specific exceptions
|
||||
_LOGGER.error("Failed to read sensor")
|
||||
```
|
||||
|
||||
### ✅ **Use These Patterns Instead**
|
||||
```python
|
||||
# Async operations with executor
|
||||
data = await hass.async_add_executor_job(requests.get, url)
|
||||
await asyncio.sleep(5) # ✅ Non-blocking
|
||||
|
||||
# Fresh BleakClient instances
|
||||
client = BleakClient(address) # ✅ New instance each time
|
||||
await client.connect()
|
||||
|
||||
# Translatable entity names
|
||||
_attr_translation_key = "temperature_sensor" # ✅ Translatable
|
||||
|
||||
# Proper error handling
|
||||
try:
|
||||
data = await self.api.get_data()
|
||||
except ApiException as err:
|
||||
raise UpdateFailed(f"API error: {err}") from err
|
||||
|
||||
# Redacted diagnostics data
|
||||
return async_redact_data(data, {"api_key", "password"}) # ✅ Safe
|
||||
|
||||
# Test through proper integration setup and fixtures
|
||||
@pytest.fixture
|
||||
async def init_integration(hass, mock_config_entry, mock_api):
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup
|
||||
|
||||
# Integration-determined polling intervals (not user-configurable)
|
||||
SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py
|
||||
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
# ✅ Integration determines interval based on device capabilities, connection type, etc.
|
||||
interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=interval,
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
```
|
||||
|
||||
17
CODEOWNERS
generated
17
CODEOWNERS
generated
@@ -234,6 +234,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/bluetooth/ @bdraco
|
||||
/homeassistant/components/bluetooth_adapters/ @bdraco
|
||||
/tests/components/bluetooth_adapters/ @bdraco
|
||||
/homeassistant/components/bmw_connected_drive/ @gerard33 @rikroe
|
||||
/tests/components/bmw_connected_drive/ @gerard33 @rikroe
|
||||
/homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
|
||||
/tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
|
||||
/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900
|
||||
@@ -279,8 +281,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/cert_expiry/ @jjlawren
|
||||
/homeassistant/components/chacon_dio/ @cnico
|
||||
/tests/components/chacon_dio/ @cnico
|
||||
/homeassistant/components/chess_com/ @joostlek
|
||||
/tests/components/chess_com/ @joostlek
|
||||
/homeassistant/components/cisco_ios/ @fbradyirl
|
||||
/homeassistant/components/cisco_mobility_express/ @fbradyirl
|
||||
/homeassistant/components/cisco_webex_teams/ @fbradyirl
|
||||
@@ -383,8 +383,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dlna_dms/ @chishm
|
||||
/homeassistant/components/dnsip/ @gjohansson-ST
|
||||
/tests/components/dnsip/ @gjohansson-ST
|
||||
/homeassistant/components/door/ @home-assistant/core
|
||||
/tests/components/door/ @home-assistant/core
|
||||
/homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket
|
||||
/tests/components/doorbird/ @oblogic7 @bdraco @flacjacket
|
||||
/homeassistant/components/dormakaba_dkey/ @emontnemery
|
||||
@@ -1202,8 +1200,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/open_meteo/ @frenck
|
||||
/homeassistant/components/open_router/ @joostlek
|
||||
/tests/components/open_router/ @joostlek
|
||||
/homeassistant/components/opendisplay/ @g4bri3lDev
|
||||
/tests/components/opendisplay/ @g4bri3lDev
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
/tests/components/openerz/ @misialq
|
||||
/homeassistant/components/openevse/ @c00w @firstof9
|
||||
@@ -1309,8 +1305,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/prosegur/ @dgomes
|
||||
/homeassistant/components/proximity/ @mib1185
|
||||
/tests/components/proximity/ @mib1185
|
||||
/homeassistant/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
|
||||
/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
|
||||
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
|
||||
/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
|
||||
/homeassistant/components/ps4/ @ktnrg45
|
||||
/tests/components/ps4/ @ktnrg45
|
||||
/homeassistant/components/pterodactyl/ @elmurato
|
||||
@@ -1654,8 +1650,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/system_bridge/ @timmo001
|
||||
/homeassistant/components/systemmonitor/ @gjohansson-ST
|
||||
/tests/components/systemmonitor/ @gjohansson-ST
|
||||
/homeassistant/components/systemnexa2/ @konsulten
|
||||
/tests/components/systemnexa2/ @konsulten
|
||||
/homeassistant/components/systemnexa2/ @konsulten @slangstrom
|
||||
/tests/components/systemnexa2/ @konsulten @slangstrom
|
||||
/homeassistant/components/tado/ @erwindouna
|
||||
/tests/components/tado/ @erwindouna
|
||||
/homeassistant/components/tag/ @home-assistant/core
|
||||
@@ -1695,6 +1691,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tessie/ @Bre77
|
||||
/homeassistant/components/text/ @home-assistant/core
|
||||
/tests/components/text/ @home-assistant/core
|
||||
/homeassistant/components/tfiac/ @fredrike @mellado
|
||||
/homeassistant/components/thermobeacon/ @bdraco
|
||||
/tests/components/thermobeacon/ @bdraco
|
||||
/homeassistant/components/thermopro/ @bdraco @h3ss
|
||||
|
||||
@@ -70,7 +70,7 @@ from .const import (
|
||||
SIGNAL_BOOTSTRAP_INTEGRATIONS,
|
||||
)
|
||||
from .core_config import async_process_ha_core_config
|
||||
from .exceptions import HomeAssistantError, UnsupportedStorageVersionError
|
||||
from .exceptions import HomeAssistantError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
category_registry,
|
||||
@@ -236,17 +236,9 @@ DEFAULT_INTEGRATIONS = {
|
||||
"input_text",
|
||||
"schedule",
|
||||
"timer",
|
||||
#
|
||||
# Base platforms:
|
||||
*BASE_PLATFORMS,
|
||||
#
|
||||
# Integrations providing triggers and conditions for base platforms:
|
||||
"door",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
"backup",
|
||||
"cloud",
|
||||
"frontend",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_SUPERVISOR = {
|
||||
@@ -441,56 +433,32 @@ def _init_blocking_io_modules_in_executor() -> None:
|
||||
is_docker_env()
|
||||
|
||||
|
||||
async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
|
||||
"""Load the registries and modules that will do blocking I/O.
|
||||
|
||||
Return whether loading succeeded.
|
||||
"""
|
||||
async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
||||
"""Load the registries and modules that will do blocking I/O."""
|
||||
if DATA_REGISTRIES_LOADED in hass.data:
|
||||
return True
|
||||
|
||||
return
|
||||
hass.data[DATA_REGISTRIES_LOADED] = None
|
||||
entity.async_setup(hass)
|
||||
frame.async_setup(hass)
|
||||
template.async_setup(hass)
|
||||
translation.async_setup(hass)
|
||||
|
||||
recovery = hass.config.recovery_mode
|
||||
try:
|
||||
await asyncio.gather(
|
||||
create_eager_task(get_internal_store_manager(hass).async_initialize()),
|
||||
create_eager_task(area_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(category_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(device_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(entity_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(floor_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(issue_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(label_registry.async_load(hass, load_empty=recovery)),
|
||||
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
|
||||
create_eager_task(template.async_load_custom_templates(hass)),
|
||||
create_eager_task(restore_state.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(hass.config_entries.async_initialize()),
|
||||
create_eager_task(async_get_system_info(hass)),
|
||||
create_eager_task(condition.async_setup(hass)),
|
||||
create_eager_task(trigger.async_setup(hass)),
|
||||
)
|
||||
except UnsupportedStorageVersionError as err:
|
||||
# If we're already in recovery mode, we don't want to handle the exception
|
||||
# and activate recovery mode again, as that would lead to an infinite loop.
|
||||
if recovery:
|
||||
raise
|
||||
|
||||
_LOGGER.error(
|
||||
"Storage file %s was created by a newer version of Home Assistant"
|
||||
" (storage version %s > %s); activating recovery mode; on-disk data"
|
||||
" is preserved; upgrade Home Assistant or restore from a backup",
|
||||
err.storage_key,
|
||||
err.found_version,
|
||||
err.max_supported_version,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
await asyncio.gather(
|
||||
create_eager_task(get_internal_store_manager(hass).async_initialize()),
|
||||
create_eager_task(area_registry.async_load(hass)),
|
||||
create_eager_task(category_registry.async_load(hass)),
|
||||
create_eager_task(device_registry.async_load(hass)),
|
||||
create_eager_task(entity_registry.async_load(hass)),
|
||||
create_eager_task(floor_registry.async_load(hass)),
|
||||
create_eager_task(issue_registry.async_load(hass)),
|
||||
create_eager_task(label_registry.async_load(hass)),
|
||||
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
|
||||
create_eager_task(template.async_load_custom_templates(hass)),
|
||||
create_eager_task(restore_state.async_load(hass)),
|
||||
create_eager_task(hass.config_entries.async_initialize()),
|
||||
create_eager_task(async_get_system_info(hass)),
|
||||
create_eager_task(condition.async_setup(hass)),
|
||||
create_eager_task(trigger.async_setup(hass)),
|
||||
)
|
||||
|
||||
|
||||
async def async_from_config_dict(
|
||||
@@ -507,9 +475,7 @@ async def async_from_config_dict(
|
||||
# Prime custom component cache early so we know if registry entries are tied
|
||||
# to a custom integration
|
||||
await loader.async_get_custom_components(hass)
|
||||
|
||||
if not await async_load_base_functionality(hass):
|
||||
return None
|
||||
await async_load_base_functionality(hass)
|
||||
|
||||
# Set up core.
|
||||
_LOGGER.debug("Setting up %s", CORE_INTEGRATIONS)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "ubisys",
|
||||
"name": "Ubisys",
|
||||
"iot_standards": ["zigbee"]
|
||||
}
|
||||
@@ -18,10 +18,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaOptionsFlowHandler,
|
||||
)
|
||||
from homeassistant.helpers.selector import BooleanSelector
|
||||
from homeassistant.helpers.service_info.zeroconf import (
|
||||
ATTR_PROPERTIES_ID,
|
||||
ZeroconfServiceInfo,
|
||||
)
|
||||
|
||||
from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE, DOMAIN
|
||||
|
||||
@@ -50,9 +46,6 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_discovered_host: str
|
||||
_discovered_name: str
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -97,58 +90,6 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery of an air-Q device."""
|
||||
self._discovered_host = discovery_info.host
|
||||
self._discovered_name = discovery_info.properties.get("devicename", "air-Q")
|
||||
device_id = discovery_info.properties.get(ATTR_PROPERTIES_ID)
|
||||
|
||||
if not device_id:
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
|
||||
await self.async_set_unique_id(device_id)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_IP_ADDRESS: self._discovered_host},
|
||||
reload_on_update=True,
|
||||
)
|
||||
|
||||
self.context["title_placeholders"] = {"name": self._discovered_name}
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user confirmation of a discovered air-Q device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
airq = AirQ(self._discovered_host, user_input[CONF_PASSWORD], session)
|
||||
try:
|
||||
await airq.validate()
|
||||
except ClientConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=self._discovered_name,
|
||||
data={
|
||||
CONF_IP_ADDRESS: self._discovered_host,
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
|
||||
description_placeholders={"name": self._discovered_name},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
|
||||
@@ -7,13 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.4.7"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
"device": "air-q"
|
||||
},
|
||||
"type": "_http._tcp.local."
|
||||
}
|
||||
]
|
||||
"requirements": ["aioairq==0.4.7"]
|
||||
}
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_input": "[%key:common::config_flow::error::invalid_host%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"description": "Do you want to set up **{name}**?",
|
||||
"title": "Set up air-Q"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
|
||||
@@ -117,23 +117,23 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
||||
return super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int:
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._unit.Temperature
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str:
|
||||
def fan_mode(self):
|
||||
"""Return fan mode of the AC this group belongs to."""
|
||||
return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._ac_number].AcFanSpeed]
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
def fan_modes(self):
|
||||
"""Return the list of available fan modes."""
|
||||
airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsForAc(self._ac_number)
|
||||
return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds]
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
def hvac_mode(self):
|
||||
"""Return hvac target hvac state."""
|
||||
is_off = self._unit.PowerState == "Off"
|
||||
if is_off:
|
||||
@@ -236,17 +236,17 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int:
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._unit.Temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> int:
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we are trying to reach."""
|
||||
return self._unit.TargetSetpoint
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
def hvac_mode(self):
|
||||
"""Return hvac target hvac state."""
|
||||
# there are other power states that aren't 'on' but still count as on (eg. 'Turbo')
|
||||
is_off = self._unit.PowerState == "Off"
|
||||
@@ -272,12 +272,12 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str:
|
||||
def fan_mode(self):
|
||||
"""Return fan mode of the AC this group belongs to."""
|
||||
return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._unit.BelongsToAc].AcFanSpeed]
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
def fan_modes(self):
|
||||
"""Return the list of available fan modes."""
|
||||
airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsByGroup(
|
||||
self._group_number
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["airtouch5py"],
|
||||
"requirements": ["airtouch5py==0.4.0"]
|
||||
"requirements": ["airtouch5py==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ from datetime import timedelta
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual.cloud_api import CloudAPI
|
||||
from pyairvisual.cloud_api import (
|
||||
CloudAPI,
|
||||
InvalidKeyError,
|
||||
KeyExpiredError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from pyairvisual.errors import AirVisualError
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
@@ -22,12 +28,14 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONF_CITY,
|
||||
@@ -39,7 +47,8 @@ from .const import (
|
||||
INTEGRATION_TYPE_NODE_PRO,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import AirVisualConfigEntry, AirVisualDataUpdateCoordinator
|
||||
|
||||
type AirVisualConfigEntry = ConfigEntry[DataUpdateCoordinator]
|
||||
|
||||
# We use a raw string for the airvisual_pro domain (instead of importing the actual
|
||||
# constant) so that we can avoid listing it as a dependency:
|
||||
@@ -76,8 +85,8 @@ def async_get_cloud_api_update_interval(
|
||||
@callback
|
||||
def async_get_cloud_coordinators_by_api_key(
|
||||
hass: HomeAssistant, api_key: str
|
||||
) -> list[AirVisualDataUpdateCoordinator]:
|
||||
"""Get all AirVisualDataUpdateCoordinator objects related to a particular API key."""
|
||||
) -> list[DataUpdateCoordinator]:
|
||||
"""Get all DataUpdateCoordinator objects related to a particular API key."""
|
||||
return [
|
||||
entry.runtime_data
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
@@ -171,11 +180,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) ->
|
||||
websession = aiohttp_client.async_get_clientsession(hass)
|
||||
cloud_api = CloudAPI(entry.data[CONF_API_KEY], session=websession)
|
||||
|
||||
coordinator = AirVisualDataUpdateCoordinator(
|
||||
async def async_update_data() -> dict[str, Any]:
|
||||
"""Get new data from the API."""
|
||||
if CONF_CITY in entry.data:
|
||||
api_coro = cloud_api.air_quality.city(
|
||||
entry.data[CONF_CITY],
|
||||
entry.data[CONF_STATE],
|
||||
entry.data[CONF_COUNTRY],
|
||||
)
|
||||
else:
|
||||
api_coro = cloud_api.air_quality.nearest_city(
|
||||
entry.data[CONF_LATITUDE],
|
||||
entry.data[CONF_LONGITUDE],
|
||||
)
|
||||
|
||||
try:
|
||||
return await api_coro
|
||||
except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
except AirVisualError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
cloud_api,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name=async_get_geography_id(entry.data),
|
||||
# We give a placeholder update interval in order to create the coordinator;
|
||||
# then, below, we use the coordinator's presence (along with any other
|
||||
# coordinators using the same API key) to calculate an actual, leveled
|
||||
# update interval:
|
||||
update_interval=timedelta(minutes=5),
|
||||
update_method=async_update_data,
|
||||
)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Define an AirVisual data coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual.cloud_api import (
|
||||
CloudAPI,
|
||||
InvalidKeyError,
|
||||
KeyExpiredError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from pyairvisual.errors import AirVisualError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_STATE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_CITY, LOGGER
|
||||
|
||||
type AirVisualConfigEntry = ConfigEntry[AirVisualDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AirVisualDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Class to manage fetching AirVisual data."""
|
||||
|
||||
config_entry: AirVisualConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: AirVisualConfigEntry,
|
||||
cloud_api: CloudAPI,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self._cloud_api = cloud_api
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name=name,
|
||||
# We give a placeholder update interval in order to create the coordinator;
|
||||
# then, in async_setup_entry, we use the coordinator's presence (along with
|
||||
# any other coordinators using the same API key) to calculate an actual,
|
||||
# leveled update interval:
|
||||
update_interval=timedelta(minutes=5),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Get new data from the API."""
|
||||
if CONF_CITY in self.config_entry.data:
|
||||
api_coro = self._cloud_api.air_quality.city(
|
||||
self.config_entry.data[CONF_CITY],
|
||||
self.config_entry.data[CONF_STATE],
|
||||
self.config_entry.data[CONF_COUNTRY],
|
||||
)
|
||||
else:
|
||||
api_coro = self._cloud_api.air_quality.nearest_city(
|
||||
self.config_entry.data[CONF_LATITUDE],
|
||||
self.config_entry.data[CONF_LONGITUDE],
|
||||
)
|
||||
|
||||
try:
|
||||
return await api_coro
|
||||
except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
except AirVisualError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
@@ -15,8 +15,8 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AirVisualConfigEntry
|
||||
from .const import CONF_CITY
|
||||
from .coordinator import AirVisualConfigEntry
|
||||
|
||||
CONF_COORDINATES = "coordinates"
|
||||
CONF_TITLE = "title"
|
||||
|
||||
@@ -2,25 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import AirVisualDataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
|
||||
class AirVisualEntity(CoordinatorEntity[AirVisualDataUpdateCoordinator]):
|
||||
class AirVisualEntity(CoordinatorEntity):
|
||||
"""Define a generic AirVisual entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirVisualDataUpdateCoordinator,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entry: ConfigEntry,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_extra_state_attributes = {}
|
||||
self._entry = entry
|
||||
self.entity_description = description
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
@@ -23,9 +24,10 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import AirVisualConfigEntry
|
||||
from .const import CONF_CITY
|
||||
from .coordinator import AirVisualConfigEntry, AirVisualDataUpdateCoordinator
|
||||
from .entity import AirVisualEntity
|
||||
|
||||
ATTR_CITY = "city"
|
||||
@@ -111,7 +113,7 @@ async def async_setup_entry(
|
||||
"""Set up AirVisual sensors based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
AirVisualGeographySensor(coordinator, description, locale)
|
||||
AirVisualGeographySensor(coordinator, entry, description, locale)
|
||||
for locale in GEOGRAPHY_SENSOR_LOCALES
|
||||
for description in GEOGRAPHY_SENSOR_DESCRIPTIONS
|
||||
)
|
||||
@@ -122,14 +124,14 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirVisualDataUpdateCoordinator,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entry: ConfigEntry,
|
||||
description: SensorEntityDescription,
|
||||
locale: str,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator, description)
|
||||
super().__init__(coordinator, entry, description)
|
||||
|
||||
entry = coordinator.config_entry
|
||||
self._attr_extra_state_attributes.update(
|
||||
{
|
||||
ATTR_CITY: entry.data.get(CONF_CITY),
|
||||
@@ -180,16 +182,16 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity):
|
||||
#
|
||||
# We use any coordinates in the config entry and, in the case of a geography by
|
||||
# name, we fall back to the latitude longitude provided in the coordinator data:
|
||||
latitude = self.coordinator.config_entry.data.get(
|
||||
latitude = self._entry.data.get(
|
||||
CONF_LATITUDE,
|
||||
self.coordinator.data["location"]["coordinates"][1],
|
||||
)
|
||||
longitude = self.coordinator.config_entry.data.get(
|
||||
longitude = self._entry.data.get(
|
||||
CONF_LONGITUDE,
|
||||
self.coordinator.data["location"]["coordinates"][0],
|
||||
)
|
||||
|
||||
if self.coordinator.config_entry.options[CONF_SHOW_ON_MAP]:
|
||||
if self._entry.options[CONF_SHOW_ON_MAP]:
|
||||
self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude
|
||||
self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude
|
||||
self._attr_extra_state_attributes.pop("lati", None)
|
||||
|
||||
@@ -4,9 +4,18 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual.node import NodeProError, NodeSamba
|
||||
from pyairvisual.node import (
|
||||
InvalidAuthenticationError,
|
||||
NodeConnectionError,
|
||||
NodeProError,
|
||||
NodeSamba,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_PASSWORD,
|
||||
@@ -14,16 +23,25 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .coordinator import (
|
||||
AirVisualProConfigEntry,
|
||||
AirVisualProCoordinator,
|
||||
AirVisualProData,
|
||||
)
|
||||
from .const import LOGGER
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
UPDATE_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
type AirVisualProConfigEntry = ConfigEntry[AirVisualProData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AirVisualProData:
|
||||
"""Define a data class."""
|
||||
|
||||
coordinator: DataUpdateCoordinator
|
||||
node: NodeSamba
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AirVisualProConfigEntry
|
||||
@@ -36,15 +54,48 @@ async def async_setup_entry(
|
||||
except NodeProError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
coordinator = AirVisualProCoordinator(hass, entry, node)
|
||||
reload_task: asyncio.Task | None = None
|
||||
|
||||
async def async_get_data() -> dict[str, Any]:
|
||||
"""Get data from the device."""
|
||||
try:
|
||||
data = await node.async_get_latest_measurements()
|
||||
data["history"] = {}
|
||||
if data["settings"].get("follow_mode") == "device":
|
||||
history = await node.async_get_history(include_trends=False)
|
||||
data["history"] = history.get("measurements", [])[-1]
|
||||
except InvalidAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed("Invalid Samba password") from err
|
||||
except NodeConnectionError as err:
|
||||
nonlocal reload_task
|
||||
if not reload_task:
|
||||
reload_task = hass.async_create_task(
|
||||
hass.config_entries.async_reload(entry.entry_id)
|
||||
)
|
||||
raise UpdateFailed(f"Connection to Pro unit lost: {err}") from err
|
||||
except NodeProError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
|
||||
return data
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name="Node/Pro data",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
update_method=async_get_data,
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = AirVisualProData(coordinator=coordinator, node=node)
|
||||
|
||||
async def async_shutdown(_: Event) -> None:
|
||||
"""Define an event handler to disconnect from the websocket."""
|
||||
if coordinator.reload_task:
|
||||
nonlocal reload_task
|
||||
if reload_task:
|
||||
with suppress(asyncio.CancelledError):
|
||||
coordinator.reload_task.cancel()
|
||||
reload_task.cancel()
|
||||
await node.async_disconnect()
|
||||
|
||||
entry.async_on_unload(
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""DataUpdateCoordinator for the AirVisual Pro integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual.node import (
|
||||
InvalidAuthenticationError,
|
||||
NodeConnectionError,
|
||||
NodeProError,
|
||||
NodeSamba,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import LOGGER
|
||||
|
||||
UPDATE_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AirVisualProData:
|
||||
"""Define a data class."""
|
||||
|
||||
coordinator: AirVisualProCoordinator
|
||||
node: NodeSamba
|
||||
|
||||
|
||||
type AirVisualProConfigEntry = ConfigEntry[AirVisualProData]
|
||||
|
||||
|
||||
class AirVisualProCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for AirVisual Pro data."""
|
||||
|
||||
config_entry: AirVisualProConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AirVisualProConfigEntry,
|
||||
node: NodeSamba,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="Node/Pro data",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self._node = node
|
||||
self.reload_task: asyncio.Task[bool] | None = None
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Get data from the device."""
|
||||
try:
|
||||
data = await self._node.async_get_latest_measurements()
|
||||
data["history"] = {}
|
||||
if data["settings"].get("follow_mode") == "device":
|
||||
history = await self._node.async_get_history(include_trends=False)
|
||||
data["history"] = history.get("measurements", [])[-1]
|
||||
except InvalidAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed("Invalid Samba password") from err
|
||||
except NodeConnectionError as err:
|
||||
if self.reload_task is None:
|
||||
self.reload_task = self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||
)
|
||||
raise UpdateFailed(f"Connection to Pro unit lost: {err}") from err
|
||||
except NodeProError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
|
||||
return data
|
||||
@@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AirVisualProConfigEntry
|
||||
from . import AirVisualProConfigEntry
|
||||
|
||||
CONF_MAC_ADDRESS = "mac_address"
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
|
||||
@@ -4,17 +4,19 @@ from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirVisualProCoordinator
|
||||
|
||||
|
||||
class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
|
||||
class AirVisualProEntity(CoordinatorEntity):
|
||||
"""Define a generic AirVisual Pro entity."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: AirVisualProCoordinator, description: EntityDescription
|
||||
self, coordinator: DataUpdateCoordinator, description: EntityDescription
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AirVisualProConfigEntry
|
||||
from . import AirVisualProConfigEntry
|
||||
from .entity import AirVisualProEntity
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ rules:
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: done
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: We can automatically remove removed devices
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
|
||||
@@ -44,7 +44,7 @@ def make_entity_state_trigger_required_features(
|
||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domains = {domain}
|
||||
_domain = domain
|
||||
_to_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Defines a base Alexa Devices entity."""
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_DEVICE_TYPE
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_MODEL
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -25,20 +25,19 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_num = serial_num
|
||||
model = self.device.model
|
||||
model_details = coordinator.api.get_model_details(self.device) or {}
|
||||
model = model_details.get("model")
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_num)},
|
||||
name=self.device.account_name,
|
||||
model=model,
|
||||
model_id=self.device.device_type,
|
||||
manufacturer=self.device.manufacturer or "Amazon",
|
||||
hw_version=self.device.hardware_version,
|
||||
manufacturer=model_details.get("manufacturer", "Amazon"),
|
||||
hw_version=model_details.get("hw_version"),
|
||||
sw_version=(
|
||||
self.device.software_version
|
||||
if model != SPEAKER_GROUP_DEVICE_TYPE
|
||||
else None
|
||||
self.device.software_version if model != SPEAKER_GROUP_MODEL else None
|
||||
),
|
||||
serial_number=serial_num if model != SPEAKER_GROUP_DEVICE_TYPE else None,
|
||||
serial_number=serial_num if model != SPEAKER_GROUP_MODEL else None,
|
||||
)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{serial_num}-{description.key}"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.0.0"]
|
||||
"requirements": ["aioamazondevices==12.0.2"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyanglianwater"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyanglianwater==3.1.1"]
|
||||
"requirements": ["pyanglianwater==3.1.0"]
|
||||
}
|
||||
|
||||
@@ -61,13 +61,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
|
||||
frequency = self.client.measure(4)
|
||||
i_leak_dcdc = self.client.measure(6)
|
||||
i_leak_inverter = self.client.measure(7)
|
||||
power_in_1 = self.client.measure(8)
|
||||
power_in_2 = self.client.measure(9)
|
||||
temperature_c = self.client.measure(21)
|
||||
voltage_in_1 = self.client.measure(23)
|
||||
current_in_1 = self.client.measure(25)
|
||||
voltage_in_2 = self.client.measure(26)
|
||||
current_in_2 = self.client.measure(27)
|
||||
r_iso = self.client.measure(30)
|
||||
energy_wh = self.client.cumulated_energy(5)
|
||||
[alarm, *_] = self.client.alarms()
|
||||
@@ -93,13 +87,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
|
||||
data["grid_frequency"] = round(frequency, 1)
|
||||
data["i_leak_dcdc"] = i_leak_dcdc
|
||||
data["i_leak_inverter"] = i_leak_inverter
|
||||
data["power_in_1"] = round(power_in_1, 1)
|
||||
data["power_in_2"] = round(power_in_2, 1)
|
||||
data["temp"] = round(temperature_c, 1)
|
||||
data["voltage_in_1"] = round(voltage_in_1, 1)
|
||||
data["current_in_1"] = round(current_in_1, 1)
|
||||
data["voltage_in_2"] = round(voltage_in_2, 1)
|
||||
data["current_in_2"] = round(current_in_2, 1)
|
||||
data["r_iso"] = r_iso
|
||||
data["totalenergy"] = round(energy_wh / 1000, 2)
|
||||
data["alarm"] = alarm
|
||||
|
||||
@@ -68,7 +68,6 @@ SENSOR_TYPES = [
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="grid_frequency",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
@@ -89,60 +88,6 @@ SENSOR_TYPES = [
|
||||
translation_key="i_leak_inverter",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power_in_1",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="power_in_1",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power_in_2",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="power_in_2",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="voltage_in_1",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="voltage_in_1",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_in_1",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="current_in_1",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="voltage_in_2",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="voltage_in_2",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_in_2",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="current_in_2",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="alarm",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
|
||||
@@ -24,18 +24,9 @@
|
||||
"alarm": {
|
||||
"name": "Alarm status"
|
||||
},
|
||||
"current_in_1": {
|
||||
"name": "String 1 current"
|
||||
},
|
||||
"current_in_2": {
|
||||
"name": "String 2 current"
|
||||
},
|
||||
"grid_current": {
|
||||
"name": "Grid current"
|
||||
},
|
||||
"grid_frequency": {
|
||||
"name": "Grid frequency"
|
||||
},
|
||||
"grid_voltage": {
|
||||
"name": "Grid voltage"
|
||||
},
|
||||
@@ -45,12 +36,6 @@
|
||||
"i_leak_inverter": {
|
||||
"name": "Inverter leak current"
|
||||
},
|
||||
"power_in_1": {
|
||||
"name": "String 1 power"
|
||||
},
|
||||
"power_in_2": {
|
||||
"name": "String 2 power"
|
||||
},
|
||||
"power_output": {
|
||||
"name": "Power output"
|
||||
},
|
||||
@@ -59,12 +44,6 @@
|
||||
},
|
||||
"total_energy": {
|
||||
"name": "Total energy"
|
||||
},
|
||||
"voltage_in_1": {
|
||||
"name": "String 1 voltage"
|
||||
},
|
||||
"voltage_in_2": {
|
||||
"name": "String 2 voltage"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,18 +142,14 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"door",
|
||||
"fan",
|
||||
"humidifier",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"number",
|
||||
"person",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"siren",
|
||||
"switch",
|
||||
"text",
|
||||
|
||||
@@ -29,17 +29,12 @@ class StoredBackupData(TypedDict):
|
||||
class _BackupStore(Store[StoredBackupData]):
|
||||
"""Class to help storing backup data."""
|
||||
|
||||
# Maximum version we support reading for forward compatibility.
|
||||
# This allows reading data written by a newer HA version after downgrade.
|
||||
_MAX_READABLE_VERSION = 2
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize storage class."""
|
||||
super().__init__(
|
||||
hass,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_KEY,
|
||||
max_readable_version=self._MAX_READABLE_VERSION,
|
||||
minor_version=STORAGE_VERSION_MINOR,
|
||||
)
|
||||
|
||||
@@ -91,8 +86,8 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
# data["config"]["schedule"]["state"] will be removed. The bump to 2 is
|
||||
# planned to happen after a 6 month quiet period with no minor version
|
||||
# changes.
|
||||
# Reject if major version is higher than _MAX_READABLE_VERSION.
|
||||
if old_major_version > self._MAX_READABLE_VERSION:
|
||||
# Reject if major version is higher than 2.
|
||||
if old_major_version > 2:
|
||||
raise NotImplementedError
|
||||
return data
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
|
||||
"""Class for binary sensor on/off triggers."""
|
||||
|
||||
_device_class: BinarySensorDeviceClass | None
|
||||
_domains = {DOMAIN}
|
||||
_domain: str = DOMAIN
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
|
||||
@@ -190,7 +190,7 @@ class BitcoinSensor(SensorEntity):
|
||||
elif sensor_type == "miners_revenue_usd":
|
||||
self._attr_native_value = f"{stats.miners_revenue_usd:.0f}"
|
||||
elif sensor_type == "btc_mined":
|
||||
self._attr_native_value = str(stats.btc_mined * 1e-8)
|
||||
self._attr_native_value = str(stats.btc_mined * 0.00000001)
|
||||
elif sensor_type == "trade_volume_usd":
|
||||
self._attr_native_value = f"{stats.trade_volume_usd:.1f}"
|
||||
elif sensor_type == "difficulty":
|
||||
@@ -208,13 +208,13 @@ class BitcoinSensor(SensorEntity):
|
||||
elif sensor_type == "blocks_size":
|
||||
self._attr_native_value = f"{stats.blocks_size:.1f}"
|
||||
elif sensor_type == "total_fees_btc":
|
||||
self._attr_native_value = f"{stats.total_fees_btc * 1e-8:.2f}"
|
||||
self._attr_native_value = f"{stats.total_fees_btc * 0.00000001:.2f}"
|
||||
elif sensor_type == "total_btc_sent":
|
||||
self._attr_native_value = f"{stats.total_btc_sent * 1e-8:.2f}"
|
||||
self._attr_native_value = f"{stats.total_btc_sent * 0.00000001:.2f}"
|
||||
elif sensor_type == "estimated_btc_sent":
|
||||
self._attr_native_value = f"{stats.estimated_btc_sent * 1e-8:.2f}"
|
||||
self._attr_native_value = f"{stats.estimated_btc_sent * 0.00000001:.2f}"
|
||||
elif sensor_type == "total_btc":
|
||||
self._attr_native_value = f"{stats.total_btc * 1e-8:.2f}"
|
||||
self._attr_native_value = f"{stats.total_btc * 0.00000001:.2f}"
|
||||
elif sensor_type == "total_blocks":
|
||||
self._attr_native_value = f"{stats.total_blocks:.0f}"
|
||||
elif sensor_type == "next_retarget":
|
||||
@@ -222,7 +222,7 @@ class BitcoinSensor(SensorEntity):
|
||||
elif sensor_type == "estimated_transaction_volume_usd":
|
||||
self._attr_native_value = f"{stats.estimated_transaction_volume_usd:.2f}"
|
||||
elif sensor_type == "miners_revenue_btc":
|
||||
self._attr_native_value = f"{stats.miners_revenue_btc * 1e-8:.1f}"
|
||||
self._attr_native_value = f"{stats.miners_revenue_btc * 0.00000001:.1f}"
|
||||
elif sensor_type == "market_price_usd":
|
||||
self._attr_native_value = f"{stats.market_price_usd:.2f}"
|
||||
|
||||
|
||||
177
homeassistant/components/bmw_connected_drive/__init__.py
Normal file
177
homeassistant/components/bmw_connected_drive/__init__.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Reads vehicle status from MyBMW portal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery,
|
||||
entity_registry as er,
|
||||
)
|
||||
|
||||
from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN
|
||||
from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SERVICE_SCHEMA = vol.Schema(
|
||||
vol.Any(
|
||||
{vol.Required(ATTR_VIN): cv.string},
|
||||
{vol.Required(CONF_DEVICE_ID): cv.string},
|
||||
)
|
||||
)
|
||||
|
||||
DEFAULT_OPTIONS = {
|
||||
CONF_READ_ONLY: False,
|
||||
}
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.LOCK,
|
||||
Platform.NOTIFY,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
SERVICE_UPDATE_STATE = "update_state"
|
||||
|
||||
|
||||
@callback
|
||||
def _async_migrate_options_from_data_if_missing(
|
||||
hass: HomeAssistant, entry: BMWConfigEntry
|
||||
) -> None:
|
||||
data = dict(entry.data)
|
||||
options = dict(entry.options)
|
||||
|
||||
if CONF_READ_ONLY in data or list(options) != list(DEFAULT_OPTIONS):
|
||||
options = dict(
|
||||
DEFAULT_OPTIONS,
|
||||
**{k: v for k, v in options.items() if k in DEFAULT_OPTIONS},
|
||||
)
|
||||
options[CONF_READ_ONLY] = data.pop(CONF_READ_ONLY, False)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||
|
||||
|
||||
async def _async_migrate_entries(
|
||||
hass: HomeAssistant, config_entry: BMWConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old entry."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
@callback
|
||||
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
|
||||
replacements = {
|
||||
Platform.SENSOR.value: {
|
||||
"charging_level_hv": "fuel_and_battery.remaining_battery_percent",
|
||||
"fuel_percent": "fuel_and_battery.remaining_fuel_percent",
|
||||
"ac_current_limit": "charging_profile.ac_current_limit",
|
||||
"charging_start_time": "fuel_and_battery.charging_start_time",
|
||||
"charging_end_time": "fuel_and_battery.charging_end_time",
|
||||
"charging_status": "fuel_and_battery.charging_status",
|
||||
"charging_target": "fuel_and_battery.charging_target",
|
||||
"remaining_battery_percent": "fuel_and_battery.remaining_battery_percent",
|
||||
"remaining_range_total": "fuel_and_battery.remaining_range_total",
|
||||
"remaining_range_electric": "fuel_and_battery.remaining_range_electric",
|
||||
"remaining_range_fuel": "fuel_and_battery.remaining_range_fuel",
|
||||
"remaining_fuel": "fuel_and_battery.remaining_fuel",
|
||||
"remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent",
|
||||
"activity": "climate.activity",
|
||||
}
|
||||
}
|
||||
if (key := entry.unique_id.split("-")[-1]) in replacements.get(
|
||||
entry.domain, []
|
||||
):
|
||||
new_unique_id = entry.unique_id.replace(
|
||||
key, replacements[entry.domain][key]
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Migrating entity '%s' unique_id from '%s' to '%s'",
|
||||
entry.entity_id,
|
||||
entry.unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
if existing_entity_id := entity_registry.async_get_entity_id(
|
||||
entry.domain, entry.platform, new_unique_id
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Cannot migrate to unique_id '%s', already exists for '%s'",
|
||||
new_unique_id,
|
||||
existing_entity_id,
|
||||
)
|
||||
return None
|
||||
return {
|
||||
"new_unique_id": new_unique_id,
|
||||
}
|
||||
return None
|
||||
|
||||
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool:
|
||||
"""Set up BMW Connected Drive from a config entry."""
|
||||
|
||||
_async_migrate_options_from_data_if_missing(hass, entry)
|
||||
|
||||
await _async_migrate_entries(hass, entry)
|
||||
|
||||
# Set up one data coordinator per account/config entry
|
||||
coordinator = BMWDataUpdateCoordinator(
|
||||
hass,
|
||||
config_entry=entry,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
# Set up all platforms except notify
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
|
||||
)
|
||||
|
||||
# set up notify platform, no entry support for notify platform yet,
|
||||
# have to use discovery to load platform.
|
||||
hass.async_create_task(
|
||||
discovery.async_load_platform(
|
||||
hass,
|
||||
Platform.NOTIFY,
|
||||
DOMAIN,
|
||||
{CONF_NAME: DOMAIN, CONF_ENTITY_ID: entry.entry_id},
|
||||
{},
|
||||
)
|
||||
)
|
||||
|
||||
# Clean up vehicles which are not assigned to the account anymore
|
||||
account_vehicles = {(DOMAIN, v.vin) for v in coordinator.account.vehicles}
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry_id=entry.entry_id
|
||||
)
|
||||
for device in device_entries:
|
||||
if not device.identifiers.intersection(account_vehicles):
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(
|
||||
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
|
||||
)
|
||||
254
homeassistant/components/bmw_connected_drive/binary_sensor.py
Normal file
254
homeassistant/components/bmw_connected_drive/binary_sensor.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Reads vehicle status from BMW MyBMW portal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.doors_windows import LockState
|
||||
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
|
||||
from bimmer_connected.vehicle.reports import ConditionBasedService
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.unit_system import UnitSystem
|
||||
|
||||
from . import BMWConfigEntry
|
||||
from .const import UNIT_MAP
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ALLOWED_CONDITION_BASED_SERVICE_KEYS = {
|
||||
"BRAKE_FLUID",
|
||||
"BRAKE_PADS_FRONT",
|
||||
"BRAKE_PADS_REAR",
|
||||
"EMISSION_CHECK",
|
||||
"ENGINE_OIL",
|
||||
"OIL",
|
||||
"TIRE_WEAR_FRONT",
|
||||
"TIRE_WEAR_REAR",
|
||||
"VEHICLE_CHECK",
|
||||
"VEHICLE_TUV",
|
||||
}
|
||||
LOGGED_CONDITION_BASED_SERVICE_WARNINGS: set[str] = set()
|
||||
|
||||
ALLOWED_CHECK_CONTROL_MESSAGE_KEYS = {
|
||||
"ENGINE_OIL",
|
||||
"TIRE_PRESSURE",
|
||||
"WASHING_FLUID",
|
||||
}
|
||||
LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS: set[str] = set()
|
||||
|
||||
|
||||
def _condition_based_services(
|
||||
vehicle: MyBMWVehicle, unit_system: UnitSystem
|
||||
) -> dict[str, Any]:
|
||||
extra_attributes = {}
|
||||
for report in vehicle.condition_based_services.messages:
|
||||
if (
|
||||
report.service_type not in ALLOWED_CONDITION_BASED_SERVICE_KEYS
|
||||
and report.service_type not in LOGGED_CONDITION_BASED_SERVICE_WARNINGS
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"'%s' not an allowed condition based service (%s)",
|
||||
report.service_type,
|
||||
report,
|
||||
)
|
||||
LOGGED_CONDITION_BASED_SERVICE_WARNINGS.add(report.service_type)
|
||||
continue
|
||||
|
||||
extra_attributes.update(_format_cbs_report(report, unit_system))
|
||||
return extra_attributes
|
||||
|
||||
|
||||
def _check_control_messages(vehicle: MyBMWVehicle) -> dict[str, Any]:
|
||||
extra_attributes: dict[str, Any] = {}
|
||||
for message in vehicle.check_control_messages.messages:
|
||||
if (
|
||||
message.description_short not in ALLOWED_CHECK_CONTROL_MESSAGE_KEYS
|
||||
and message.description_short not in LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"'%s' not an allowed check control message (%s)",
|
||||
message.description_short,
|
||||
message,
|
||||
)
|
||||
LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS.add(message.description_short)
|
||||
continue
|
||||
|
||||
extra_attributes[message.description_short.lower()] = message.state.value
|
||||
return extra_attributes
|
||||
|
||||
|
||||
def _format_cbs_report(
|
||||
report: ConditionBasedService, unit_system: UnitSystem
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
service_type = report.service_type.lower()
|
||||
result[service_type] = report.state.value
|
||||
if report.due_date is not None:
|
||||
result[f"{service_type}_date"] = report.due_date.strftime("%Y-%m-%d")
|
||||
if report.due_distance.value and report.due_distance.unit:
|
||||
distance = round(
|
||||
unit_system.length(
|
||||
report.due_distance.value,
|
||||
UNIT_MAP.get(report.due_distance.unit, report.due_distance.unit),
|
||||
)
|
||||
)
|
||||
result[f"{service_type}_distance"] = f"{distance} {unit_system.length_unit}"
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes BMW binary_sensor entity."""
|
||||
|
||||
value_fn: Callable[[MyBMWVehicle], bool]
|
||||
attr_fn: Callable[[MyBMWVehicle, UnitSystem], dict[str, Any]] | None = None
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="lids",
|
||||
translation_key="lids",
|
||||
device_class=BinarySensorDeviceClass.OPENING,
|
||||
# device class opening: On means open, Off means closed
|
||||
value_fn=lambda v: not v.doors_and_windows.all_lids_closed,
|
||||
attr_fn=lambda v, u: {
|
||||
lid.name: lid.state.value for lid in v.doors_and_windows.lids
|
||||
},
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="windows",
|
||||
translation_key="windows",
|
||||
device_class=BinarySensorDeviceClass.OPENING,
|
||||
# device class opening: On means open, Off means closed
|
||||
value_fn=lambda v: not v.doors_and_windows.all_windows_closed,
|
||||
attr_fn=lambda v, u: {
|
||||
window.name: window.state.value for window in v.doors_and_windows.windows
|
||||
},
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="door_lock_state",
|
||||
translation_key="door_lock_state",
|
||||
device_class=BinarySensorDeviceClass.LOCK,
|
||||
# device class lock: On means unlocked, Off means locked
|
||||
# Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED
|
||||
value_fn=lambda v: (
|
||||
v.doors_and_windows.door_lock_state
|
||||
not in {LockState.LOCKED, LockState.SECURED}
|
||||
),
|
||||
attr_fn=lambda v, u: {
|
||||
"door_lock_state": v.doors_and_windows.door_lock_state.value
|
||||
},
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="condition_based_services",
|
||||
translation_key="condition_based_services",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
# device class problem: On means problem detected, Off means no problem
|
||||
value_fn=lambda v: v.condition_based_services.is_service_required,
|
||||
attr_fn=_condition_based_services,
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="check_control_messages",
|
||||
translation_key="check_control_messages",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
# device class problem: On means problem detected, Off means no problem
|
||||
value_fn=lambda v: v.check_control_messages.has_check_control_messages,
|
||||
attr_fn=lambda v, u: _check_control_messages(v),
|
||||
),
|
||||
# electric
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="charging_status",
|
||||
translation_key="charging_status",
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
# device class power: On means power detected, Off means no power
|
||||
value_fn=lambda v: v.fuel_and_battery.charging_status == ChargingState.CHARGING,
|
||||
is_available=lambda v: v.has_electric_drivetrain,
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="connection_status",
|
||||
translation_key="connection_status",
|
||||
device_class=BinarySensorDeviceClass.PLUG,
|
||||
value_fn=lambda v: v.fuel_and_battery.is_charger_connected,
|
||||
is_available=lambda v: v.has_electric_drivetrain,
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="is_pre_entry_climatization_enabled",
|
||||
translation_key="is_pre_entry_climatization_enabled",
|
||||
value_fn=lambda v: (
|
||||
v.charging_profile.is_pre_entry_climatization_enabled
|
||||
if v.charging_profile
|
||||
else False
|
||||
),
|
||||
is_available=lambda v: v.has_electric_drivetrain,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the BMW binary sensors from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities = [
|
||||
BMWBinarySensor(coordinator, vehicle, description, hass.config.units)
|
||||
for vehicle in coordinator.account.vehicles
|
||||
for description in SENSOR_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWBinarySensor(BMWBaseEntity, BinarySensorEntity):
|
||||
"""Representation of a BMW vehicle binary sensor."""
|
||||
|
||||
entity_description: BMWBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWBinarySensorEntityDescription,
|
||||
unit_system: UnitSystem,
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._unit_system = unit_system
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug(
|
||||
"Updating binary sensor '%s' of %s",
|
||||
self.entity_description.key,
|
||||
self.vehicle.name,
|
||||
)
|
||||
self._attr_is_on = self.entity_description.value_fn(self.vehicle)
|
||||
|
||||
if self.entity_description.attr_fn:
|
||||
self._attr_extra_state_attributes = self.entity_description.attr_fn(
|
||||
self.vehicle, self._unit_system
|
||||
)
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
127
homeassistant/components/bmw_connected_drive/button.py
Normal file
127
homeassistant/components/bmw_connected_drive/button.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Support for MyBMW button entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.remote_services import RemoteServiceStatus
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Class describing BMW button entities."""
|
||||
|
||||
remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]]
|
||||
enabled_when_read_only: bool = False
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: True
|
||||
|
||||
|
||||
BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = (
|
||||
BMWButtonEntityDescription(
|
||||
key="light_flash",
|
||||
translation_key="light_flash",
|
||||
remote_function=lambda vehicle: (
|
||||
vehicle.remote_services.trigger_remote_light_flash()
|
||||
),
|
||||
),
|
||||
BMWButtonEntityDescription(
|
||||
key="sound_horn",
|
||||
translation_key="sound_horn",
|
||||
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(),
|
||||
),
|
||||
BMWButtonEntityDescription(
|
||||
key="activate_air_conditioning",
|
||||
translation_key="activate_air_conditioning",
|
||||
remote_function=lambda vehicle: (
|
||||
vehicle.remote_services.trigger_remote_air_conditioning()
|
||||
),
|
||||
),
|
||||
BMWButtonEntityDescription(
|
||||
key="deactivate_air_conditioning",
|
||||
translation_key="deactivate_air_conditioning",
|
||||
remote_function=lambda vehicle: (
|
||||
vehicle.remote_services.trigger_remote_air_conditioning_stop()
|
||||
),
|
||||
is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled,
|
||||
),
|
||||
BMWButtonEntityDescription(
|
||||
key="find_vehicle",
|
||||
translation_key="find_vehicle",
|
||||
remote_function=lambda vehicle: (
|
||||
vehicle.remote_services.trigger_remote_vehicle_finder()
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the BMW buttons from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BMWButton] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
entities.extend(
|
||||
[
|
||||
BMWButton(coordinator, vehicle, description)
|
||||
for description in BUTTON_TYPES
|
||||
if (not coordinator.read_only and description.is_available(vehicle))
|
||||
or (coordinator.read_only and description.enabled_when_read_only)
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWButton(BMWBaseEntity, ButtonEntity):
|
||||
"""Representation of a MyBMW button."""
|
||||
|
||||
entity_description: BMWButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize BMW vehicle sensor."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
try:
|
||||
await self.entity_description.remote_function(self.vehicle)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
277
homeassistant/components/bmw_connected_drive/config_flow.py
Normal file
277
homeassistant/components/bmw_connected_drive/config_flow.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""Config flow for BMW ConnectedDrive integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.api.authentication import MyBMWAuthentication
|
||||
from bimmer_connected.api.regions import get_region_from_name
|
||||
from bimmer_connected.models import (
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from httpx import RequestError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from . import DOMAIN
|
||||
from .const import (
|
||||
CONF_ALLOWED_REGIONS,
|
||||
CONF_CAPTCHA_REGIONS,
|
||||
CONF_CAPTCHA_TOKEN,
|
||||
CONF_CAPTCHA_URL,
|
||||
CONF_GCID,
|
||||
CONF_READ_ONLY,
|
||||
CONF_REFRESH_TOKEN,
|
||||
)
|
||||
from .coordinator import BMWConfigEntry
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_REGION): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=CONF_ALLOWED_REGIONS,
|
||||
translation_key="regions",
|
||||
)
|
||||
),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
RECONFIGURE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
CAPTCHA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CAPTCHA_TOKEN): str,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
auth = MyBMWAuthentication(
|
||||
data[CONF_USERNAME],
|
||||
data[CONF_PASSWORD],
|
||||
get_region_from_name(data[CONF_REGION]),
|
||||
hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN),
|
||||
verify=get_default_context(),
|
||||
)
|
||||
|
||||
try:
|
||||
await auth.login()
|
||||
except MyBMWCaptchaMissingError as ex:
|
||||
raise MissingCaptcha from ex
|
||||
except MyBMWAuthError as ex:
|
||||
raise InvalidAuth from ex
|
||||
except (MyBMWAPIError, RequestError) as ex:
|
||||
raise CannotConnect from ex
|
||||
|
||||
# Return info that you want to store in the config entry.
|
||||
retval = {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"}
|
||||
if auth.refresh_token:
|
||||
retval[CONF_REFRESH_TOKEN] = auth.refresh_token
|
||||
if auth.gcid:
|
||||
retval[CONF_GCID] = auth.gcid
|
||||
return retval
|
||||
|
||||
|
||||
class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for MyBMW."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self._existing_entry_data: dict[str, Any] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = self.data.pop("errors", {})
|
||||
|
||||
if user_input is not None and not errors:
|
||||
unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}"
|
||||
await self.async_set_unique_id(unique_id)
|
||||
|
||||
# Unique ID cannot change for reauth/reconfigure
|
||||
if self.source not in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Store user input for later use
|
||||
self.data.update(user_input)
|
||||
|
||||
# North America and Rest of World require captcha token
|
||||
if (
|
||||
self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS
|
||||
and CONF_CAPTCHA_TOKEN not in self.data
|
||||
):
|
||||
return await self.async_step_captcha()
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = await validate_input(self.hass, self.data)
|
||||
except MissingCaptcha:
|
||||
errors["base"] = "missing_captcha"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
finally:
|
||||
self.data.pop(CONF_CAPTCHA_TOKEN, None)
|
||||
|
||||
if info:
|
||||
entry_data = {
|
||||
**self.data,
|
||||
CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
|
||||
CONF_GCID: info.get(CONF_GCID),
|
||||
}
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=entry_data
|
||||
)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data=entry_data,
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=info["title"],
|
||||
data=entry_data,
|
||||
)
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
DATA_SCHEMA,
|
||||
self._existing_entry_data or self.data,
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_change_password(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the change password step."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_user(self._existing_entry_data | user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="change_password",
|
||||
data_schema=RECONFIGURE_SCHEMA,
|
||||
description_placeholders={
|
||||
CONF_USERNAME: self._existing_entry_data[CONF_USERNAME],
|
||||
CONF_REGION: self._existing_entry_data[CONF_REGION],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
self._existing_entry_data = dict(entry_data)
|
||||
return await self.async_step_change_password()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reconfiguration flow initialized by the user."""
|
||||
self._existing_entry_data = dict(self._get_reconfigure_entry().data)
|
||||
return await self.async_step_change_password()
|
||||
|
||||
async def async_step_captcha(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show captcha form."""
|
||||
if user_input and user_input.get(CONF_CAPTCHA_TOKEN):
|
||||
self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip()
|
||||
return await self.async_step_user(self.data)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="captcha",
|
||||
data_schema=CAPTCHA_SCHEMA,
|
||||
description_placeholders={
|
||||
"captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION])
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: BMWConfigEntry,
|
||||
) -> BMWOptionsFlow:
|
||||
"""Return a MyBMW option flow."""
|
||||
return BMWOptionsFlow()
|
||||
|
||||
|
||||
class BMWOptionsFlow(OptionsFlow):
|
||||
"""Handle a option flow for MyBMW."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
return await self.async_step_account_options()
|
||||
|
||||
async def async_step_account_options(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is not None:
|
||||
# Manually update & reload the config entry after options change.
|
||||
# Required as each successful login will store the latest refresh_token
|
||||
# using async_update_entry, which would otherwise trigger a full reload
|
||||
# if the options would be refreshed using a listener.
|
||||
changed = self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
options=user_input,
|
||||
)
|
||||
if changed:
|
||||
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
return self.async_show_form(
|
||||
step_id="account_options",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_READ_ONLY,
|
||||
default=self.config_entry.options.get(CONF_READ_ONLY, False),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class MissingCaptcha(HomeAssistantError):
|
||||
"""Error to indicate the captcha token is missing."""
|
||||
34
homeassistant/components/bmw_connected_drive/const.py
Normal file
34
homeassistant/components/bmw_connected_drive/const.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Const file for the MyBMW integration."""
|
||||
|
||||
from homeassistant.const import UnitOfLength, UnitOfVolume
|
||||
|
||||
DOMAIN = "bmw_connected_drive"
|
||||
|
||||
ATTR_DIRECTION = "direction"
|
||||
ATTR_VIN = "vin"
|
||||
|
||||
CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"]
|
||||
CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"]
|
||||
CONF_READ_ONLY = "read_only"
|
||||
CONF_ACCOUNT = "account"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
CONF_GCID = "gcid"
|
||||
CONF_CAPTCHA_TOKEN = "captcha_token"
|
||||
CONF_CAPTCHA_URL = (
|
||||
"https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html"
|
||||
)
|
||||
|
||||
DATA_HASS_CONFIG = "hass_config"
|
||||
|
||||
UNIT_MAP = {
|
||||
"KILOMETERS": UnitOfLength.KILOMETERS,
|
||||
"MILES": UnitOfLength.MILES,
|
||||
"LITERS": UnitOfVolume.LITERS,
|
||||
"GALLONS": UnitOfVolume.GALLONS,
|
||||
}
|
||||
|
||||
SCAN_INTERVALS = {
|
||||
"china": 300,
|
||||
"north_america": 600,
|
||||
"rest_of_world": 300,
|
||||
}
|
||||
113
homeassistant/components/bmw_connected_drive/coordinator.py
Normal file
113
homeassistant/components/bmw_connected_drive/coordinator.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Coordinator for BMW."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from bimmer_connected.account import MyBMWAccount
|
||||
from bimmer_connected.api.regions import get_region_from_name
|
||||
from bimmer_connected.models import (
|
||||
GPSPosition,
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from httpx import RequestError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type BMWConfigEntry = ConfigEntry[BMWDataUpdateCoordinator]
|
||||
|
||||
|
||||
class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Class to manage fetching BMW data."""
|
||||
|
||||
account: MyBMWAccount
|
||||
config_entry: BMWConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, *, config_entry: BMWConfigEntry) -> None:
|
||||
"""Initialize account-wide BMW data updater."""
|
||||
self.account = MyBMWAccount(
|
||||
config_entry.data[CONF_USERNAME],
|
||||
config_entry.data[CONF_PASSWORD],
|
||||
get_region_from_name(config_entry.data[CONF_REGION]),
|
||||
observer_position=GPSPosition(hass.config.latitude, hass.config.longitude),
|
||||
verify=get_default_context(),
|
||||
)
|
||||
self.read_only: bool = config_entry.options[CONF_READ_ONLY]
|
||||
|
||||
if CONF_REFRESH_TOKEN in config_entry.data:
|
||||
self.account.set_refresh_token(
|
||||
refresh_token=config_entry.data[CONF_REFRESH_TOKEN],
|
||||
gcid=config_entry.data.get(CONF_GCID),
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}",
|
||||
update_interval=timedelta(
|
||||
seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]]
|
||||
),
|
||||
)
|
||||
|
||||
# Default to false on init so _async_update_data logic works
|
||||
self.last_update_success = False
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from BMW."""
|
||||
old_refresh_token = self.account.refresh_token
|
||||
|
||||
try:
|
||||
await self.account.get_vehicles()
|
||||
except MyBMWCaptchaMissingError as err:
|
||||
# If a captcha is required (user/password login flow), always trigger the reauth flow
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_captcha",
|
||||
) from err
|
||||
except MyBMWAuthError as err:
|
||||
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
|
||||
if self.last_update_success:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
# Clear refresh token and trigger reauth if previous update failed as well
|
||||
self._update_config_entry_refresh_token(None)
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except (MyBMWAPIError, RequestError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
|
||||
if self.account.refresh_token != old_refresh_token:
|
||||
self._update_config_entry_refresh_token(self.account.refresh_token)
|
||||
|
||||
def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None:
|
||||
"""Update or delete the refresh_token in the Config Entry."""
|
||||
data = {
|
||||
**self.config_entry.data,
|
||||
CONF_REFRESH_TOKEN: refresh_token,
|
||||
}
|
||||
if not refresh_token:
|
||||
data.pop(CONF_REFRESH_TOKEN)
|
||||
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Device tracker for MyBMW vehicles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BMWConfigEntry
|
||||
from .const import ATTR_DIRECTION
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW tracker from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities: list[BMWDeviceTracker] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
entities.append(BMWDeviceTracker(coordinator, vehicle))
|
||||
if not vehicle.is_vehicle_tracking_enabled:
|
||||
_LOGGER.info(
|
||||
(
|
||||
"Tracking is (currently) disabled for vehicle %s (%s), defaulting"
|
||||
" to unknown"
|
||||
),
|
||||
vehicle.name,
|
||||
vehicle.vin,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWDeviceTracker(BMWBaseEntity, TrackerEntity):
|
||||
"""MyBMW device tracker."""
|
||||
|
||||
_attr_force_update = False
|
||||
_attr_translation_key = "car"
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
) -> None:
|
||||
"""Initialize the Tracker."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self._attr_unique_id = vehicle.vin
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return entity specific state attributes."""
|
||||
return {ATTR_DIRECTION: self.vehicle.vehicle_location.heading}
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
return (
|
||||
self.vehicle.vehicle_location.location[0]
|
||||
if self.vehicle.is_vehicle_tracking_enabled
|
||||
and self.vehicle.vehicle_location.location
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
return (
|
||||
self.vehicle.vehicle_location.location[1]
|
||||
if self.vehicle.is_vehicle_tracking_enabled
|
||||
and self.vehicle.vehicle_location.location
|
||||
else None
|
||||
)
|
||||
100
homeassistant/components/bmw_connected_drive/diagnostics.py
Normal file
100
homeassistant/components/bmw_connected_drive/diagnostics.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Diagnostics support for the BMW Connected Drive integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from bimmer_connected.utils import MyBMWJSONEncoder
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from . import BMWConfigEntry
|
||||
from .const import CONF_REFRESH_TOKEN
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
|
||||
TO_REDACT_INFO = [CONF_USERNAME, CONF_PASSWORD, CONF_REFRESH_TOKEN]
|
||||
TO_REDACT_DATA = [
|
||||
"lat",
|
||||
"latitude",
|
||||
"lon",
|
||||
"longitude",
|
||||
"heading",
|
||||
"vin",
|
||||
"licensePlate",
|
||||
"city",
|
||||
"street",
|
||||
"streetNumber",
|
||||
"postalCode",
|
||||
"phone",
|
||||
"formatted",
|
||||
"subtitle",
|
||||
]
|
||||
|
||||
|
||||
def vehicle_to_dict(vehicle: MyBMWVehicle | None) -> dict:
|
||||
"""Convert a MyBMWVehicle to a dictionary using MyBMWJSONEncoder."""
|
||||
retval: dict = json.loads(json.dumps(vehicle, cls=MyBMWJSONEncoder))
|
||||
return retval
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: BMWConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
coordinator.account.config.log_responses = True
|
||||
await coordinator.account.get_vehicles(force_init=True)
|
||||
|
||||
diagnostics_data = {
|
||||
"info": async_redact_data(config_entry.data, TO_REDACT_INFO),
|
||||
"data": [
|
||||
async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA)
|
||||
for vehicle in coordinator.account.vehicles
|
||||
],
|
||||
"fingerprint": async_redact_data(
|
||||
[asdict(r) for r in coordinator.account.get_stored_responses()],
|
||||
TO_REDACT_DATA,
|
||||
),
|
||||
}
|
||||
|
||||
coordinator.account.config.log_responses = False
|
||||
|
||||
return diagnostics_data
|
||||
|
||||
|
||||
async def async_get_device_diagnostics(
|
||||
hass: HomeAssistant, config_entry: BMWConfigEntry, device: DeviceEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a device."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
coordinator.account.config.log_responses = True
|
||||
await coordinator.account.get_vehicles(force_init=True)
|
||||
|
||||
vin = next(iter(device.identifiers))[1]
|
||||
vehicle = coordinator.account.get_vehicle(vin)
|
||||
|
||||
diagnostics_data = {
|
||||
"info": async_redact_data(config_entry.data, TO_REDACT_INFO),
|
||||
"data": async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA),
|
||||
# Always have to get the full fingerprint as the VIN is redacted beforehand by the library
|
||||
"fingerprint": async_redact_data(
|
||||
[asdict(r) for r in coordinator.account.get_stored_responses()],
|
||||
TO_REDACT_DATA,
|
||||
),
|
||||
}
|
||||
|
||||
coordinator.account.config.log_responses = False
|
||||
|
||||
return diagnostics_data
|
||||
40
homeassistant/components/bmw_connected_drive/entity.py
Normal file
40
homeassistant/components/bmw_connected_drive/entity.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Base for all BMW entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
|
||||
|
||||
class BMWBaseEntity(CoordinatorEntity[BMWDataUpdateCoordinator]):
|
||||
"""Common base for BMW entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.vehicle = vehicle
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, vehicle.vin)},
|
||||
manufacturer=vehicle.brand.name,
|
||||
model=vehicle.name,
|
||||
name=vehicle.name,
|
||||
serial_number=vehicle.vin,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._handle_coordinator_update()
|
||||
102
homeassistant/components/bmw_connected_drive/icons.json
Normal file
102
homeassistant/components/bmw_connected_drive/icons.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"charging_status": {
|
||||
"default": "mdi:ev-station"
|
||||
},
|
||||
"check_control_messages": {
|
||||
"default": "mdi:car-tire-alert"
|
||||
},
|
||||
"condition_based_services": {
|
||||
"default": "mdi:wrench"
|
||||
},
|
||||
"connection_status": {
|
||||
"default": "mdi:car-electric"
|
||||
},
|
||||
"door_lock_state": {
|
||||
"default": "mdi:car-key"
|
||||
},
|
||||
"is_pre_entry_climatization_enabled": {
|
||||
"default": "mdi:car-seat-heater"
|
||||
},
|
||||
"lids": {
|
||||
"default": "mdi:car-door-lock"
|
||||
},
|
||||
"windows": {
|
||||
"default": "mdi:car-door"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"activate_air_conditioning": {
|
||||
"default": "mdi:hvac"
|
||||
},
|
||||
"deactivate_air_conditioning": {
|
||||
"default": "mdi:hvac-off"
|
||||
},
|
||||
"find_vehicle": {
|
||||
"default": "mdi:crosshairs-question"
|
||||
},
|
||||
"light_flash": {
|
||||
"default": "mdi:car-light-alert"
|
||||
},
|
||||
"sound_horn": {
|
||||
"default": "mdi:bullhorn"
|
||||
}
|
||||
},
|
||||
"device_tracker": {
|
||||
"car": {
|
||||
"default": "mdi:car"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"target_soc": {
|
||||
"default": "mdi:battery-charging-medium"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"ac_limit": {
|
||||
"default": "mdi:current-ac"
|
||||
},
|
||||
"charging_mode": {
|
||||
"default": "mdi:vector-point-select"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"charging_status": {
|
||||
"default": "mdi:ev-station"
|
||||
},
|
||||
"charging_target": {
|
||||
"default": "mdi:battery-charging-high"
|
||||
},
|
||||
"climate_status": {
|
||||
"default": "mdi:fan"
|
||||
},
|
||||
"mileage": {
|
||||
"default": "mdi:speedometer"
|
||||
},
|
||||
"remaining_fuel": {
|
||||
"default": "mdi:gas-station"
|
||||
},
|
||||
"remaining_fuel_percent": {
|
||||
"default": "mdi:gas-station"
|
||||
},
|
||||
"remaining_range_electric": {
|
||||
"default": "mdi:map-marker-distance"
|
||||
},
|
||||
"remaining_range_fuel": {
|
||||
"default": "mdi:map-marker-distance"
|
||||
},
|
||||
"remaining_range_total": {
|
||||
"default": "mdi:map-marker-distance"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"charging": {
|
||||
"default": "mdi:ev-station"
|
||||
},
|
||||
"climate": {
|
||||
"default": "mdi:fan"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
homeassistant/components/bmw_connected_drive/lock.py
Normal file
121
homeassistant/components/bmw_connected_drive/lock.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Support for BMW car locks with BMW ConnectedDrive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.doors_windows import LockState
|
||||
|
||||
from homeassistant.components.lock import LockEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
DOOR_LOCK_STATE = "door_lock_state"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW lock from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
if not coordinator.read_only:
|
||||
async_add_entities(
|
||||
BMWLock(coordinator, vehicle) for vehicle in coordinator.account.vehicles
|
||||
)
|
||||
|
||||
|
||||
class BMWLock(BMWBaseEntity, LockEntity):
|
||||
"""Representation of a MyBMW vehicle lock."""
|
||||
|
||||
_attr_translation_key = "lock"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
) -> None:
|
||||
"""Initialize the lock."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
|
||||
self._attr_unique_id = f"{vehicle.vin}-lock"
|
||||
self.door_lock_state_available = vehicle.is_lsc_enabled
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the car."""
|
||||
_LOGGER.debug("%s: locking doors", self.vehicle.name)
|
||||
# Only update the HA state machine if the vehicle reliably reports its lock state
|
||||
if self.door_lock_state_available:
|
||||
# Optimistic state set here because it takes some time before the
|
||||
# update callback response
|
||||
self._attr_is_locked = True
|
||||
self.async_write_ha_state()
|
||||
try:
|
||||
await self.vehicle.remote_services.trigger_remote_door_lock()
|
||||
except MyBMWAPIError as ex:
|
||||
# Set the state to unknown if the command fails
|
||||
self._attr_is_locked = None
|
||||
self.async_write_ha_state()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
finally:
|
||||
# Always update the listeners to get the latest state
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the car."""
|
||||
_LOGGER.debug("%s: unlocking doors", self.vehicle.name)
|
||||
# Only update the HA state machine if the vehicle reliably reports its lock state
|
||||
if self.door_lock_state_available:
|
||||
# Optimistic state set here because it takes some time before the
|
||||
# update callback response
|
||||
self._attr_is_locked = False
|
||||
self.async_write_ha_state()
|
||||
try:
|
||||
await self.vehicle.remote_services.trigger_remote_door_unlock()
|
||||
except MyBMWAPIError as ex:
|
||||
# Set the state to unknown if the command fails
|
||||
self._attr_is_locked = None
|
||||
self.async_write_ha_state()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
finally:
|
||||
# Always update the listeners to get the latest state
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug("Updating lock data of %s", self.vehicle.name)
|
||||
|
||||
# Only update the HA state machine if the vehicle reliably reports its lock state
|
||||
if self.door_lock_state_available:
|
||||
self._attr_is_locked = self.vehicle.doors_and_windows.door_lock_state in {
|
||||
LockState.LOCKED,
|
||||
LockState.SECURED,
|
||||
}
|
||||
self._attr_extra_state_attributes = {
|
||||
DOOR_LOCK_STATE: self.vehicle.doors_and_windows.door_lock_state.value
|
||||
}
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
11
homeassistant/components/bmw_connected_drive/manifest.json
Normal file
11
homeassistant/components/bmw_connected_drive/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "bmw_connected_drive",
|
||||
"name": "BMW Connected Drive",
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"requirements": ["bimmer-connected[china]==0.17.3"]
|
||||
}
|
||||
113
homeassistant/components/bmw_connected_drive/notify.py
Normal file
113
homeassistant/components/bmw_connected_drive/notify.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Support for BMW notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError, PointOfInterest
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
ATTR_TARGET,
|
||||
BaseNotificationService,
|
||||
)
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"]
|
||||
|
||||
POI_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_LATITUDE): cv.latitude,
|
||||
vol.Required(ATTR_LONGITUDE): cv.longitude,
|
||||
vol.Optional("street"): cv.string,
|
||||
vol.Optional("city"): cv.string,
|
||||
vol.Optional("postal_code"): cv.string,
|
||||
vol.Optional("country"): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_service(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> BMWNotificationService:
|
||||
"""Get the BMW notification service."""
|
||||
config_entry: BMWConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
(discovery_info or {})[CONF_ENTITY_ID]
|
||||
)
|
||||
|
||||
targets = {}
|
||||
if (
|
||||
config_entry
|
||||
and (coordinator := config_entry.runtime_data)
|
||||
and not coordinator.read_only
|
||||
):
|
||||
targets.update({v.name: v for v in coordinator.account.vehicles})
|
||||
return BMWNotificationService(targets)
|
||||
|
||||
|
||||
class BMWNotificationService(BaseNotificationService):
|
||||
"""Send Notifications to BMW."""
|
||||
|
||||
vehicle_targets: dict[str, MyBMWVehicle]
|
||||
|
||||
def __init__(self, targets: dict[str, MyBMWVehicle]) -> None:
|
||||
"""Set up the notification service."""
|
||||
self.vehicle_targets = targets
|
||||
|
||||
@property
|
||||
def targets(self) -> dict[str, Any] | None:
|
||||
"""Return a dictionary of registered targets."""
|
||||
return self.vehicle_targets
|
||||
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message or POI to the car."""
|
||||
|
||||
try:
|
||||
# Verify data schema
|
||||
poi_data = kwargs.get(ATTR_DATA) or {}
|
||||
POI_SCHEMA(poi_data)
|
||||
|
||||
# Create the POI object
|
||||
poi = PointOfInterest(
|
||||
lat=poi_data.pop(ATTR_LATITUDE),
|
||||
lon=poi_data.pop(ATTR_LONGITUDE),
|
||||
name=(message or None),
|
||||
**poi_data,
|
||||
)
|
||||
|
||||
except (vol.Invalid, TypeError, ValueError) as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_poi",
|
||||
translation_placeholders={
|
||||
"poi_exception": str(ex),
|
||||
},
|
||||
) from ex
|
||||
|
||||
for vehicle in kwargs[ATTR_TARGET]:
|
||||
vehicle = cast(MyBMWVehicle, vehicle)
|
||||
_LOGGER.debug("Sending message to %s", vehicle.name)
|
||||
|
||||
try:
|
||||
await vehicle.remote_services.trigger_send_poi(poi)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
118
homeassistant/components/bmw_connected_drive/number.py
Normal file
118
homeassistant/components/bmw_connected_drive/number.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Number platform for BMW."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes BMW number entity."""
|
||||
|
||||
value_fn: Callable[[MyBMWVehicle], float | int | None]
|
||||
remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]]
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
|
||||
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
|
||||
|
||||
|
||||
NUMBER_TYPES: list[BMWNumberEntityDescription] = [
|
||||
BMWNumberEntityDescription(
|
||||
key="target_soc",
|
||||
translation_key="target_soc",
|
||||
device_class=NumberDeviceClass.BATTERY,
|
||||
is_available=lambda v: v.is_remote_set_target_soc_enabled,
|
||||
native_max_value=100.0,
|
||||
native_min_value=20.0,
|
||||
native_step=5.0,
|
||||
mode=NumberMode.SLIDER,
|
||||
value_fn=lambda v: v.fuel_and_battery.charging_target,
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
|
||||
target_soc=int(o)
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW number from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BMWNumber] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
if not coordinator.read_only:
|
||||
entities.extend(
|
||||
[
|
||||
BMWNumber(coordinator, vehicle, description)
|
||||
for description in NUMBER_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWNumber(BMWBaseEntity, NumberEntity):
|
||||
"""Representation of BMW Number entity."""
|
||||
|
||||
entity_description: BMWNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize an BMW Number."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the entity value to represent the entity state."""
|
||||
return self.entity_description.value_fn(self.vehicle)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update to the vehicle."""
|
||||
_LOGGER.debug(
|
||||
"Executing '%s' on vehicle '%s' to value '%s'",
|
||||
self.entity_description.key,
|
||||
self.vehicle.vin,
|
||||
value,
|
||||
)
|
||||
try:
|
||||
await self.entity_description.remote_service(self.vehicle, value)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
107
homeassistant/components/bmw_connected_drive/quality_scale.yaml
Normal file
107
homeassistant/components/bmw_connected_drive/quality_scale.yaml
Normal file
@@ -0,0 +1,107 @@
|
||||
# + in comment indicates requirement for quality scale
|
||||
# - in comment indicates issue to be fixed, not impacting quality scale
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Does not have custom services
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules:
|
||||
status: done
|
||||
comment: |
|
||||
- 2 states writes in async_added_to_hass() required for platforms that redefine _handle_coordinator_update()
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
- test_show_form doesn't really add anything
|
||||
- Patch bimmer_connected imports with homeassistant.components.bmw_connected_drive.bimmer_connected imports
|
||||
+ Ensure that configs flows end in CREATE_ENTRY or ABORT
|
||||
- Parameterize test_authentication_error, test_api_error and test_connection_error
|
||||
+ test_full_user_flow_implementation doesn't assert unique id of created entry
|
||||
+ test that aborts when a mocked config entry already exists
|
||||
+ don't test on internals (e.g. `coordinator.last_update_success`) but rather on the resulting state (change)
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
Does not have custom services
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't have any events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
Does not have custom services
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: done
|
||||
comment: |
|
||||
- Use constants in tests where possible
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This integration doesn't use discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This integration doesn't use discovery.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: todo
|
||||
comment: >
|
||||
To be discussed.
|
||||
We cannot regularly get new devices/vehicles due to API quota limitations.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
Other than reauthentication, this integration doesn't have any cases where raising an issue is needed.
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: >
|
||||
To be discussed.
|
||||
We cannot regularly check for stale devices/vehicles due to API quota limitations.
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: todo
|
||||
comment: >
|
||||
To be discussed.
|
||||
The library requires a custom client for API authentication, with custom auth lifecycle and user agents.
|
||||
strict-typing: done
|
||||
132
homeassistant/components/bmw_connected_drive/select.py
Normal file
132
homeassistant/components/bmw_connected_drive/select.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Select platform for BMW."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.charging_profile import ChargingMode
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import UnitOfElectricCurrent
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWSelectEntityDescription(SelectEntityDescription):
|
||||
"""Describes BMW sensor entity."""
|
||||
|
||||
current_option: Callable[[MyBMWVehicle], str]
|
||||
remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]]
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
|
||||
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
|
||||
|
||||
|
||||
SELECT_TYPES: tuple[BMWSelectEntityDescription, ...] = (
|
||||
BMWSelectEntityDescription(
|
||||
key="ac_limit",
|
||||
translation_key="ac_limit",
|
||||
is_available=lambda v: v.is_remote_set_ac_limit_enabled,
|
||||
dynamic_options=lambda v: [
|
||||
str(lim)
|
||||
for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr]
|
||||
],
|
||||
current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr]
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
|
||||
ac_limit=int(o)
|
||||
),
|
||||
unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
),
|
||||
BMWSelectEntityDescription(
|
||||
key="charging_mode",
|
||||
translation_key="charging_mode",
|
||||
is_available=lambda v: v.is_charging_plan_supported,
|
||||
options=[c.value.lower() for c in ChargingMode if c != ChargingMode.UNKNOWN],
|
||||
current_option=lambda v: v.charging_profile.charging_mode.value.lower(), # type: ignore[union-attr]
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update(
|
||||
charging_mode=ChargingMode(o)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW lock from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BMWSelect] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
if not coordinator.read_only:
|
||||
entities.extend(
|
||||
[
|
||||
BMWSelect(coordinator, vehicle, description)
|
||||
for description in SELECT_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWSelect(BMWBaseEntity, SelectEntity):
|
||||
"""Representation of BMW select entity."""
|
||||
|
||||
entity_description: BMWSelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize an BMW select."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
if description.dynamic_options:
|
||||
self._attr_options = description.dynamic_options(vehicle)
|
||||
self._attr_current_option = description.current_option(vehicle)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug(
|
||||
"Updating select '%s' of %s", self.entity_description.key, self.vehicle.name
|
||||
)
|
||||
self._attr_current_option = self.entity_description.current_option(self.vehicle)
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Update to the vehicle."""
|
||||
_LOGGER.debug(
|
||||
"Executing '%s' on vehicle '%s' to value '%s'",
|
||||
self.entity_description.key,
|
||||
self.vehicle.vin,
|
||||
option,
|
||||
)
|
||||
try:
|
||||
await self.entity_description.remote_service(self.vehicle, option)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
250
homeassistant/components/bmw_connected_drive/sensor.py
Normal file
250
homeassistant/components/bmw_connected_drive/sensor.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Support for reading vehicle status from MyBMW portal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from bimmer_connected.models import StrEnum, ValueWithUnit
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.climate import ClimateActivityState
|
||||
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfLength,
|
||||
UnitOfPressure,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BMWSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes BMW sensor entity."""
|
||||
|
||||
key_class: str | None = None
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled
|
||||
|
||||
|
||||
TIRES = ["front_left", "front_right", "rear_left", "rear_right"]
|
||||
|
||||
SENSOR_TYPES: list[BMWSensorEntityDescription] = [
|
||||
BMWSensorEntityDescription(
|
||||
key="charging_profile.ac_current_limit",
|
||||
translation_key="ac_current_limit",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.charging_start_time",
|
||||
translation_key="charging_start_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_registry_enabled_default=False,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.charging_end_time",
|
||||
translation_key="charging_end_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.charging_status",
|
||||
translation_key="charging_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[s.value.lower() for s in ChargingState if s != ChargingState.UNKNOWN],
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.charging_target",
|
||||
translation_key="charging_target",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_battery_percent",
|
||||
translation_key="remaining_battery_percent",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="mileage",
|
||||
translation_key="mileage",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_range_total",
|
||||
translation_key="remaining_range_total",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_range_electric",
|
||||
translation_key="remaining_range_electric",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_range_fuel",
|
||||
translation_key="remaining_range_fuel",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_fuel",
|
||||
translation_key="remaining_fuel",
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_fuel_percent",
|
||||
translation_key="remaining_fuel_percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="climate.activity",
|
||||
translation_key="climate_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
s.value.lower()
|
||||
for s in ClimateActivityState
|
||||
if s != ClimateActivityState.UNKNOWN
|
||||
],
|
||||
is_available=lambda v: v.is_remote_climate_stop_enabled,
|
||||
),
|
||||
*[
|
||||
BMWSensorEntityDescription(
|
||||
key=f"tires.{tire}.current_pressure",
|
||||
translation_key=f"{tire}_current_pressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.KPA,
|
||||
suggested_unit_of_measurement=UnitOfPressure.BAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.tires is not None,
|
||||
)
|
||||
for tire in TIRES
|
||||
],
|
||||
*[
|
||||
BMWSensorEntityDescription(
|
||||
key=f"tires.{tire}.target_pressure",
|
||||
translation_key=f"{tire}_target_pressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.KPA,
|
||||
suggested_unit_of_measurement=UnitOfPressure.BAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.tires is not None,
|
||||
)
|
||||
for tire in TIRES
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW sensors from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities = [
|
||||
BMWSensor(coordinator, vehicle, description)
|
||||
for vehicle in coordinator.account.vehicles
|
||||
for description in SENSOR_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWSensor(BMWBaseEntity, SensorEntity):
|
||||
"""Representation of a BMW vehicle sensor."""
|
||||
|
||||
entity_description: BMWSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize BMW vehicle sensor."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug(
|
||||
"Updating sensor '%s' of %s", self.entity_description.key, self.vehicle.name
|
||||
)
|
||||
|
||||
key_path = self.entity_description.key.split(".")
|
||||
state = getattr(self.vehicle, key_path.pop(0))
|
||||
|
||||
for key in key_path:
|
||||
state = getattr(state, key)
|
||||
|
||||
# For datetime without tzinfo, we assume it to be the same timezone as the HA instance
|
||||
if isinstance(state, datetime.datetime) and state.tzinfo is None:
|
||||
state = state.replace(tzinfo=dt_util.get_default_time_zone())
|
||||
# For enum types, we only want the value
|
||||
elif isinstance(state, ValueWithUnit):
|
||||
state = state.value
|
||||
# Get lowercase values from StrEnum
|
||||
elif isinstance(state, StrEnum):
|
||||
state = state.value.lower()
|
||||
if state == STATE_UNKNOWN:
|
||||
state = None
|
||||
|
||||
self._attr_native_value = state
|
||||
super()._handle_coordinator_update()
|
||||
248
homeassistant/components/bmw_connected_drive/strings.json
Normal file
248
homeassistant/components/bmw_connected_drive/strings.json
Normal file
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_captcha": "Captcha validation missing"
|
||||
},
|
||||
"step": {
|
||||
"captcha": {
|
||||
"data": {
|
||||
"captcha_token": "Captcha token"
|
||||
},
|
||||
"data_description": {
|
||||
"captcha_token": "One-time token retrieved from the captcha challenge."
|
||||
},
|
||||
"description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.",
|
||||
"title": "Are you a robot?"
|
||||
},
|
||||
"change_password": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::bmw_connected_drive::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Update your MyBMW/MINI Connected password for account `{username}` in region `{region}`."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"region": "ConnectedDrive region",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The password of your MyBMW/MINI Connected account.",
|
||||
"region": "The region of your MyBMW/MINI Connected account.",
|
||||
"username": "The email address of your MyBMW/MINI Connected account."
|
||||
},
|
||||
"description": "Connect to your MyBMW/MINI Connected account to retrieve vehicle data."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"charging_status": {
|
||||
"name": "Charging status"
|
||||
},
|
||||
"check_control_messages": {
|
||||
"name": "Check control messages"
|
||||
},
|
||||
"condition_based_services": {
|
||||
"name": "Condition-based services"
|
||||
},
|
||||
"connection_status": {
|
||||
"name": "Connection status"
|
||||
},
|
||||
"door_lock_state": {
|
||||
"name": "Door lock state"
|
||||
},
|
||||
"is_pre_entry_climatization_enabled": {
|
||||
"name": "Pre-entry climatization"
|
||||
},
|
||||
"lids": {
|
||||
"name": "Lids"
|
||||
},
|
||||
"windows": {
|
||||
"name": "Windows"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"activate_air_conditioning": {
|
||||
"name": "Activate air conditioning"
|
||||
},
|
||||
"deactivate_air_conditioning": {
|
||||
"name": "Deactivate air conditioning"
|
||||
},
|
||||
"find_vehicle": {
|
||||
"name": "Find vehicle"
|
||||
},
|
||||
"light_flash": {
|
||||
"name": "Flash lights"
|
||||
},
|
||||
"sound_horn": {
|
||||
"name": "Sound horn"
|
||||
}
|
||||
},
|
||||
"lock": {
|
||||
"lock": {
|
||||
"name": "[%key:component::lock::title%]"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"target_soc": {
|
||||
"name": "Target SoC"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"ac_limit": {
|
||||
"name": "AC charging limit"
|
||||
},
|
||||
"charging_mode": {
|
||||
"name": "Charging mode",
|
||||
"state": {
|
||||
"delayed_charging": "Delayed charging",
|
||||
"immediate_charging": "Immediate charging",
|
||||
"no_action": "No action"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ac_current_limit": {
|
||||
"name": "AC current limit"
|
||||
},
|
||||
"charging_end_time": {
|
||||
"name": "Charging end time"
|
||||
},
|
||||
"charging_start_time": {
|
||||
"name": "Charging start time"
|
||||
},
|
||||
"charging_status": {
|
||||
"name": "Charging status",
|
||||
"state": {
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"complete": "Complete",
|
||||
"default": "Default",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"finished_fully_charged": "Finished, fully charged",
|
||||
"finished_not_full": "Finished, not full",
|
||||
"fully_charged": "Fully charged",
|
||||
"invalid": "Invalid",
|
||||
"not_charging": "Not charging",
|
||||
"plugged_in": "Plugged in",
|
||||
"target_reached": "Target reached",
|
||||
"waiting_for_charging": "Waiting for charging"
|
||||
}
|
||||
},
|
||||
"charging_target": {
|
||||
"name": "Charging target"
|
||||
},
|
||||
"climate_status": {
|
||||
"name": "Climate status",
|
||||
"state": {
|
||||
"cooling": "Cooling",
|
||||
"heating": "Heating",
|
||||
"inactive": "Inactive",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"ventilation": "Ventilation"
|
||||
}
|
||||
},
|
||||
"front_left_current_pressure": {
|
||||
"name": "Front left tire pressure"
|
||||
},
|
||||
"front_left_target_pressure": {
|
||||
"name": "Front left target pressure"
|
||||
},
|
||||
"front_right_current_pressure": {
|
||||
"name": "Front right tire pressure"
|
||||
},
|
||||
"front_right_target_pressure": {
|
||||
"name": "Front right target pressure"
|
||||
},
|
||||
"mileage": {
|
||||
"name": "Mileage"
|
||||
},
|
||||
"rear_left_current_pressure": {
|
||||
"name": "Rear left tire pressure"
|
||||
},
|
||||
"rear_left_target_pressure": {
|
||||
"name": "Rear left target pressure"
|
||||
},
|
||||
"rear_right_current_pressure": {
|
||||
"name": "Rear right tire pressure"
|
||||
},
|
||||
"rear_right_target_pressure": {
|
||||
"name": "Rear right target pressure"
|
||||
},
|
||||
"remaining_battery_percent": {
|
||||
"name": "Remaining battery percent"
|
||||
},
|
||||
"remaining_fuel": {
|
||||
"name": "Remaining fuel"
|
||||
},
|
||||
"remaining_fuel_percent": {
|
||||
"name": "Remaining fuel percent"
|
||||
},
|
||||
"remaining_range_electric": {
|
||||
"name": "Remaining range electric"
|
||||
},
|
||||
"remaining_range_fuel": {
|
||||
"name": "Remaining range fuel"
|
||||
},
|
||||
"remaining_range_total": {
|
||||
"name": "Remaining range total"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"charging": {
|
||||
"name": "Charging"
|
||||
},
|
||||
"climate": {
|
||||
"name": "Climate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_auth": {
|
||||
"message": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"invalid_poi": {
|
||||
"message": "Invalid data for point of interest: {poi_exception}"
|
||||
},
|
||||
"missing_captcha": {
|
||||
"message": "Login requires captcha validation"
|
||||
},
|
||||
"remote_service_error": {
|
||||
"message": "Error executing remote service on vehicle. {exception}"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Error updating vehicle data. {exception}"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"account_options": {
|
||||
"data": {
|
||||
"read_only": "Read-only mode"
|
||||
},
|
||||
"data_description": {
|
||||
"read_only": "Only retrieve values and send POI data, but don't offer any services that can change the vehicle state."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"regions": {
|
||||
"options": {
|
||||
"china": "China",
|
||||
"north_america": "North America",
|
||||
"rest_of_world": "Rest of world"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
homeassistant/components/bmw_connected_drive/switch.py
Normal file
133
homeassistant/components/bmw_connected_drive/switch.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Switch platform for BMW."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describes BMW switch entity."""
|
||||
|
||||
value_fn: Callable[[MyBMWVehicle], bool]
|
||||
remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]]
|
||||
remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]]
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
|
||||
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
|
||||
|
||||
|
||||
CHARGING_STATE_ON = {
|
||||
ChargingState.CHARGING,
|
||||
ChargingState.COMPLETE,
|
||||
ChargingState.FULLY_CHARGED,
|
||||
ChargingState.FINISHED_FULLY_CHARGED,
|
||||
ChargingState.FINISHED_NOT_FULL,
|
||||
ChargingState.TARGET_REACHED,
|
||||
}
|
||||
|
||||
NUMBER_TYPES: list[BMWSwitchEntityDescription] = [
|
||||
BMWSwitchEntityDescription(
|
||||
key="climate",
|
||||
translation_key="climate",
|
||||
is_available=lambda v: v.is_remote_climate_stop_enabled,
|
||||
value_fn=lambda v: v.climate.is_climate_on,
|
||||
remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(),
|
||||
remote_service_off=lambda v: (
|
||||
v.remote_services.trigger_remote_air_conditioning_stop()
|
||||
),
|
||||
),
|
||||
BMWSwitchEntityDescription(
|
||||
key="charging",
|
||||
translation_key="charging",
|
||||
is_available=lambda v: v.is_remote_charge_stop_enabled,
|
||||
value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON,
|
||||
remote_service_on=lambda v: v.remote_services.trigger_charge_start(),
|
||||
remote_service_off=lambda v: v.remote_services.trigger_charge_stop(),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW switch from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BMWSwitch] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
if not coordinator.read_only:
|
||||
entities.extend(
|
||||
[
|
||||
BMWSwitch(coordinator, vehicle, description)
|
||||
for description in NUMBER_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWSwitch(BMWBaseEntity, SwitchEntity):
|
||||
"""Representation of BMW Switch entity."""
|
||||
|
||||
entity_description: BMWSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize an BMW Switch."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the entity value to represent the entity state."""
|
||||
return self.entity_description.value_fn(self.vehicle)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
try:
|
||||
await self.entity_description.remote_service_on(self.vehicle)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
try:
|
||||
await self.entity_description.remote_service_off(self.vehicle)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
self.coordinator.async_update_listeners()
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.1.2"],
|
||||
"requirements": ["python-bsblan==5.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -14,7 +14,7 @@ from . import DOMAIN
|
||||
class ButtonPressedTrigger(EntityTriggerBase):
|
||||
"""Trigger for button entity presses."""
|
||||
|
||||
_domains = {DOMAIN}
|
||||
_domain = DOMAIN
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
|
||||
@@ -29,12 +29,6 @@
|
||||
"early_update": {
|
||||
"default": "mdi:update"
|
||||
},
|
||||
"equalizer": {
|
||||
"default": "mdi:equalizer",
|
||||
"state": {
|
||||
"off": "mdi:equalizer-outline"
|
||||
}
|
||||
},
|
||||
"pre_amp": {
|
||||
"default": "mdi:volume-high",
|
||||
"state": {
|
||||
|
||||
@@ -65,9 +65,6 @@
|
||||
"early_update": {
|
||||
"name": "Early update"
|
||||
},
|
||||
"equalizer": {
|
||||
"name": "Equalizer"
|
||||
},
|
||||
"pre_amp": {
|
||||
"name": "Pre-Amp"
|
||||
},
|
||||
|
||||
@@ -33,13 +33,6 @@ def room_correction_enabled(client: StreamMagicClient) -> bool:
|
||||
return client.audio.tilt_eq.enabled
|
||||
|
||||
|
||||
def equalizer_enabled(client: StreamMagicClient) -> bool:
|
||||
"""Check if equalizer is enabled."""
|
||||
if TYPE_CHECKING:
|
||||
assert client.audio.user_eq is not None
|
||||
return client.audio.user_eq.enabled
|
||||
|
||||
|
||||
CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
|
||||
CambridgeAudioSwitchEntityDescription(
|
||||
key="pre_amp",
|
||||
@@ -63,14 +56,6 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
|
||||
value_fn=room_correction_enabled,
|
||||
set_value_fn=lambda client, value: client.set_room_correction_mode(value),
|
||||
),
|
||||
CambridgeAudioSwitchEntityDescription(
|
||||
key="equalizer",
|
||||
translation_key="equalizer",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
load_fn=lambda client: client.audio.user_eq is not None,
|
||||
value_fn=equalizer_enabled,
|
||||
set_value_fn=lambda client, value: client.set_equalizer_mode(value),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["casttube", "pychromecast"],
|
||||
"requirements": ["PyChromecast==14.0.10"],
|
||||
"requirements": ["PyChromecast==14.0.9"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_googlecast._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -804,22 +804,8 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the state of the player."""
|
||||
if (chromecast := self._chromecast) is None or (
|
||||
cast_status := self.cast_status
|
||||
) is None:
|
||||
# Not connected to any chromecast, or not yet got any status
|
||||
return None
|
||||
|
||||
if (
|
||||
chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST
|
||||
and not chromecast.ignore_cec
|
||||
and cast_status.is_active_input is False
|
||||
):
|
||||
# The display interface for the device has been turned off or switched away
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
# The lovelace app loops media to prevent timing out, don't show that
|
||||
if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
|
||||
# The lovelace app loops media to prevent timing out, don't show that
|
||||
return MediaPlayerState.PLAYING
|
||||
|
||||
if (media_status := self._media_status()[0]) is not None:
|
||||
@@ -836,12 +822,16 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
# Some apps don't report media status, show the player as playing
|
||||
return MediaPlayerState.PLAYING
|
||||
|
||||
if self.app_id in (pychromecast.IDLE_APP_ID, None):
|
||||
# We have no active app or the home screen app. This is
|
||||
# same app as APP_BACKDROP.
|
||||
if self.app_id is not None and self.app_id != pychromecast.config.APP_BACKDROP:
|
||||
# We have an active app
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
if self._chromecast is not None and self._chromecast.is_idle:
|
||||
# If library consider us idle, that is our off state
|
||||
# it takes HDMI status into account for cast devices.
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
return MediaPlayerState.IDLE
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_content_id(self) -> str | None:
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""The Chess.com integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import ChessConfigEntry, ChessCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ChessConfigEntry) -> bool:
|
||||
"""Set up Chess.com from a config entry."""
|
||||
|
||||
coordinator = ChessCoordinator(hass, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ChessConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Config flow for the Chess.com integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from chess_com_api import ChessComClient, NotFoundError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChessConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Chess.com."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = ChessComClient(session=session)
|
||||
try:
|
||||
user = await client.get_player(user_input[CONF_USERNAME])
|
||||
except NotFoundError:
|
||||
errors["base"] = "player_not_found"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(str(user.player_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=user.name, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Constants for the Chess.com integration."""
|
||||
|
||||
DOMAIN = "chess_com"
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Coordinator for Chess.com."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from chess_com_api import ChessComAPIError, ChessComClient, Player, PlayerStats
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type ChessConfigEntry = ConfigEntry[ChessCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChessData:
|
||||
"""Data for Chess.com."""
|
||||
|
||||
player: Player
|
||||
stats: PlayerStats
|
||||
|
||||
|
||||
class ChessCoordinator(DataUpdateCoordinator[ChessData]):
|
||||
"""Coordinator for Chess.com."""
|
||||
|
||||
config_entry: ChessConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ChessConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=config_entry.title,
|
||||
update_interval=timedelta(hours=1),
|
||||
)
|
||||
self.client = ChessComClient(session=async_get_clientsession(hass))
|
||||
|
||||
async def _async_update_data(self) -> ChessData:
|
||||
"""Update data from Chess.com."""
|
||||
try:
|
||||
player = await self.client.get_player(self.config_entry.data[CONF_USERNAME])
|
||||
stats = await self.client.get_player_stats(
|
||||
self.config_entry.data[CONF_USERNAME]
|
||||
)
|
||||
except ChessComAPIError as err:
|
||||
raise UpdateFailed(f"Error communicating with Chess.com: {err}") from err
|
||||
return ChessData(player=player, stats=stats)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Base entity for Chess.com integration."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ChessCoordinator
|
||||
|
||||
|
||||
class ChessEntity(CoordinatorEntity[ChessCoordinator]):
|
||||
"""Base entity for Chess.com integration."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: ChessCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
if TYPE_CHECKING:
|
||||
assert coordinator.config_entry.unique_id is not None
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer="Chess.com",
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"chess_daily_rating": {
|
||||
"default": "mdi:chart-line"
|
||||
},
|
||||
"followers": {
|
||||
"default": "mdi:account-multiple"
|
||||
},
|
||||
"total_daily_draw": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"total_daily_lost": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"total_daily_won": {
|
||||
"default": "mdi:chess-pawn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "chess_com",
|
||||
"name": "Chess.com",
|
||||
"codeowners": ["@joostlek"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/chess_com",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["chess_com_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["chess-com-api==1.1.0"]
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: There are no custom actions
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: There are no custom actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities do not explicitly subscribe to events
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: There are no configuration parameters
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Can't detect a game
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Can't detect a game
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: There are no repairable issues
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Sensor platform for Chess.com integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ChessConfigEntry
|
||||
from .coordinator import ChessCoordinator, ChessData
|
||||
from .entity import ChessEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ChessEntityDescription(SensorEntityDescription):
|
||||
"""Sensor description for Chess.com player."""
|
||||
|
||||
value_fn: Callable[[ChessData], float]
|
||||
|
||||
|
||||
SENSORS: tuple[ChessEntityDescription, ...] = (
|
||||
ChessEntityDescription(
|
||||
key="followers",
|
||||
translation_key="followers",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda state: state.player.followers,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="chess_daily_rating",
|
||||
translation_key="chess_daily_rating",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda state: state.stats.chess_daily["last"]["rating"],
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="total_daily_won",
|
||||
translation_key="total_daily_won",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda state: state.stats.chess_daily["record"]["win"],
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="total_daily_lost",
|
||||
translation_key="total_daily_lost",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda state: state.stats.chess_daily["record"]["loss"],
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="total_daily_draw",
|
||||
translation_key="total_daily_draw",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda state: state.stats.chess_daily["record"]["draw"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ChessConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Initialize the entries."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
ChessPlayerSensor(coordinator, description) for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
class ChessPlayerSensor(ChessEntity, SensorEntity):
|
||||
"""Chess.com sensor."""
|
||||
|
||||
entity_description: ChessEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ChessCoordinator,
|
||||
description: ChessEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}.{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"player_not_found": "Player not found.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add player"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "The Chess.com username of the player to monitor."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"chess_daily_rating": {
|
||||
"name": "Daily chess rating"
|
||||
},
|
||||
"followers": {
|
||||
"name": "Followers",
|
||||
"unit_of_measurement": "followers"
|
||||
},
|
||||
"total_daily_draw": {
|
||||
"name": "Total chess games drawn",
|
||||
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
|
||||
},
|
||||
"total_daily_lost": {
|
||||
"name": "Total chess games lost",
|
||||
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
|
||||
},
|
||||
"total_daily_won": {
|
||||
"name": "Total chess games won",
|
||||
"unit_of_measurement": "games"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
|
||||
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domains = {DOMAIN}
|
||||
_domain = DOMAIN
|
||||
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
|
||||
@@ -48,8 +48,6 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
vol.Optional("conversation_id"): vol.Any(str, None),
|
||||
vol.Optional("language"): str,
|
||||
vol.Optional("agent_id"): agent_id_validator,
|
||||
vol.Optional("device_id"): vol.Any(str, None),
|
||||
vol.Optional("satellite_id"): vol.Any(str, None),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -66,8 +64,6 @@ async def websocket_process(
|
||||
context=connection.context(msg),
|
||||
language=msg.get("language"),
|
||||
agent_id=msg.get("agent_id"),
|
||||
device_id=msg.get("device_id"),
|
||||
satellite_id=msg.get("satellite_id"),
|
||||
)
|
||||
connection.send_result(msg["id"], result.as_dict())
|
||||
|
||||
@@ -252,8 +248,6 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
vol.Optional("conversation_id"): str,
|
||||
vol.Optional("language"): str,
|
||||
vol.Optional("agent_id"): agent_id_validator,
|
||||
vol.Optional("device_id"): vol.Any(str, None),
|
||||
vol.Optional("satellite_id"): vol.Any(str, None),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -268,8 +262,6 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
context=self.context(request),
|
||||
language=data.get("language"),
|
||||
agent_id=data.get("agent_id"),
|
||||
device_id=data.get("device_id"),
|
||||
satellite_id=data.get("satellite_id"),
|
||||
)
|
||||
|
||||
return self.json(result.as_dict())
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.3"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.13"]
|
||||
}
|
||||
|
||||
@@ -107,17 +107,17 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._unit.temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we are trying to reach."""
|
||||
return self._unit.thermostat
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
def hvac_mode(self):
|
||||
"""Return hvac target hvac state."""
|
||||
mode = self._unit.mode
|
||||
if not self._unit.is_on:
|
||||
@@ -126,7 +126,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
return CM_TO_HA_STATE[mode]
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str:
|
||||
def fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
|
||||
# Normalize to lowercase for lookup, and pass unknown lowercase values through.
|
||||
@@ -145,7 +145,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
return CM_TO_HA_FAN[fan_speed_lower]
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
def fan_modes(self):
|
||||
"""Return the list of available fan modes."""
|
||||
return FAN_MODES
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ class CoverEntityFeature(IntFlag):
|
||||
|
||||
ATTR_CURRENT_POSITION = "current_position"
|
||||
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
|
||||
ATTR_IS_CLOSED = "is_closed"
|
||||
ATTR_POSITION = "position"
|
||||
ATTR_TILT_POSITION = "tilt_position"
|
||||
|
||||
@@ -268,9 +267,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
data: dict[str, Any] = {}
|
||||
|
||||
data[ATTR_IS_CLOSED] = self.is_closed
|
||||
data = {}
|
||||
|
||||
if (current := self.current_cover_position) is not None:
|
||||
data[ATTR_CURRENT_POSITION] = current
|
||||
|
||||
@@ -112,12 +112,11 @@ def _zone_is_configured(zone: DaikinZone) -> bool:
|
||||
|
||||
def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]:
|
||||
"""Return the decoded zone temperature lists."""
|
||||
values = device.values
|
||||
if DAIKIN_ZONE_TEMP_HEAT not in values or DAIKIN_ZONE_TEMP_COOL not in values:
|
||||
try:
|
||||
heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1]
|
||||
cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1]
|
||||
except AttributeError, KeyError:
|
||||
return ([], [])
|
||||
|
||||
heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1]
|
||||
cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1]
|
||||
return (list(heating or []), list(cooling or []))
|
||||
|
||||
|
||||
|
||||
@@ -30,16 +30,9 @@ async def async_setup_entry(
|
||||
async_add_entities(
|
||||
[
|
||||
DemoWaterHeater(
|
||||
"demo_water_heater",
|
||||
"Demo Water Heater",
|
||||
119,
|
||||
UnitOfTemperature.FAHRENHEIT,
|
||||
False,
|
||||
"eco",
|
||||
1,
|
||||
"Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco", 1
|
||||
),
|
||||
DemoWaterHeater(
|
||||
"demo_water_heater_celsius",
|
||||
"Demo Water Heater Celsius",
|
||||
45,
|
||||
UnitOfTemperature.CELSIUS,
|
||||
@@ -59,7 +52,6 @@ class DemoWaterHeater(WaterHeaterEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
target_temperature: int,
|
||||
unit_of_measurement: str,
|
||||
@@ -68,7 +60,6 @@ class DemoWaterHeater(WaterHeaterEntity):
|
||||
target_temperature_step: float,
|
||||
) -> None:
|
||||
"""Initialize the water_heater device."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
if target_temperature is not None:
|
||||
self._attr_supported_features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"host": "Host"
|
||||
},
|
||||
"description": "Please enter the host name or IP address of the Devialet device."
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Integration for door triggers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "door"
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
return True
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:door-closed"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:door-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"domain": "door",
|
||||
"name": "Door",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/door",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted doors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Door",
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"description": "Triggers after one or more doors close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::door::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::door::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Door closed"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more doors open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::door::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::door::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Door opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Provides triggers for doors."""
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.cover import ATTR_IS_CLOSED, DOMAIN as COVER_DOMAIN
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
DEVICE_CLASS_DOOR = "door"
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class DoorTriggerBase(EntityTriggerBase):
|
||||
"""Base trigger for door state changes."""
|
||||
|
||||
_domains = {BINARY_SENSOR_DOMAIN, COVER_DOMAIN}
|
||||
_binary_sensor_target_state: str
|
||||
_cover_is_closed_target_value: bool
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities by door device class."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id) == DEVICE_CLASS_DOOR
|
||||
}
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target door state."""
|
||||
if split_entity_id(state.entity_id)[0] == COVER_DOMAIN:
|
||||
return (
|
||||
state.attributes.get(ATTR_IS_CLOSED)
|
||||
== self._cover_is_closed_target_value
|
||||
)
|
||||
return state.state == self._binary_sensor_target_state
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the transition is valid for a door state change."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
if split_entity_id(from_state.entity_id)[0] == COVER_DOMAIN:
|
||||
if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None:
|
||||
return False
|
||||
return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED)
|
||||
return from_state.state != to_state.state
|
||||
|
||||
|
||||
class DoorOpenedTrigger(DoorTriggerBase):
|
||||
"""Trigger for door opened state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_ON
|
||||
_cover_is_closed_target_value = False
|
||||
|
||||
|
||||
class DoorClosedTrigger(DoorTriggerBase):
|
||||
"""Trigger for door closed state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_OFF
|
||||
_cover_is_closed_target_value = True
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": DoorOpenedTrigger,
|
||||
"closed": DoorClosedTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for doors."""
|
||||
return TRIGGERS
|
||||
@@ -1,29 +0,0 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: door
|
||||
- domain: cover
|
||||
device_class: door
|
||||
|
||||
opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: door
|
||||
- domain: cover
|
||||
device_class: door
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["dsmr_parser"],
|
||||
"requirements": ["dsmr-parser==1.5.0"]
|
||||
"requirements": ["dsmr-parser==1.4.3"]
|
||||
}
|
||||
|
||||
@@ -2,39 +2,14 @@
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EafmConfigEntry, EafmCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
def _fix_device_registry_identifiers(
|
||||
hass: HomeAssistant, entry: EafmConfigEntry
|
||||
) -> None:
|
||||
"""Fix invalid identifiers in device registry.
|
||||
|
||||
Added in 2026.4, can be removed in 2026.10 or later.
|
||||
"""
|
||||
device_registry = dr.async_get(hass)
|
||||
for device_entry in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
old_identifier = (DOMAIN, "measure-id", entry.data["station"])
|
||||
if old_identifier not in device_entry.identifiers: # type: ignore[comparison-overlap]
|
||||
continue
|
||||
new_identifiers = device_entry.identifiers.copy()
|
||||
new_identifiers.discard(old_identifier) # type: ignore[arg-type]
|
||||
new_identifiers.add((DOMAIN, entry.data["station"]))
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, new_identifiers=new_identifiers
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EafmConfigEntry) -> bool:
|
||||
"""Set up flood monitoring sensors for this config entry."""
|
||||
_fix_device_registry_identifiers(hass, entry)
|
||||
coordinator = EafmCoordinator(hass, entry=entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
@@ -94,11 +94,11 @@ class Measurement(CoordinatorEntity, SensorEntity):
|
||||
return self.coordinator.data["measures"][self.key]["parameterName"]
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
def device_info(self):
|
||||
"""Return the device info."""
|
||||
return DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, self.station_id)},
|
||||
identifiers={(DOMAIN, "measure-id", self.station_id)},
|
||||
manufacturer="https://environment.data.gov.uk/",
|
||||
model=self.parameter_name,
|
||||
name=f"{self.station_name} {self.parameter_name} {self.qualifier}",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["paho_mqtt", "pyeconet"],
|
||||
"requirements": ["pyeconet==0.2.2"]
|
||||
"requirements": ["pyeconet==0.2.1"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry
|
||||
|
||||
TO_REDACT = {"emailAddr", "usrName", "api_usrName", "api_password"}
|
||||
TO_REDACT = {"emailAddr", "usrName"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
||||
@@ -8,7 +8,6 @@ from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.heater import EheimDigitalHeater
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
from eheimdigital.types import HeaterUnit
|
||||
|
||||
from homeassistant.components.number import (
|
||||
@@ -45,47 +44,6 @@ class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
|
||||
uom_fn: Callable[[_DeviceT], str] | None = None
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalNumberDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalNumberDescription[EheimDigitalReeflexUV](
|
||||
key="daily_burn_time",
|
||||
translation_key="daily_burn_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_min_value=0,
|
||||
native_max_value=1440,
|
||||
value_fn=lambda device: device.daily_burn_time,
|
||||
set_value_fn=lambda device, value: device.set_daily_burn_time(int(value)),
|
||||
),
|
||||
EheimDigitalNumberDescription[EheimDigitalReeflexUV](
|
||||
key="booster_time",
|
||||
translation_key="booster_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_min_value=0,
|
||||
native_max_value=20160,
|
||||
value_fn=lambda device: device.booster_time,
|
||||
set_value_fn=lambda device, value: device.set_booster_time(int(value)),
|
||||
),
|
||||
EheimDigitalNumberDescription[EheimDigitalReeflexUV](
|
||||
key="pause_time",
|
||||
translation_key="pause_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_min_value=0,
|
||||
native_max_value=20160,
|
||||
value_fn=lambda device: device.pause_time,
|
||||
set_value_fn=lambda device, value: device.set_pause_time(int(value)),
|
||||
),
|
||||
)
|
||||
|
||||
FILTER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalFilter], ...] = (
|
||||
EheimDigitalNumberDescription[EheimDigitalFilter](
|
||||
key="high_pulse_time",
|
||||
@@ -231,13 +189,6 @@ async def async_setup_entry(
|
||||
)
|
||||
for description in HEATER_DESCRIPTIONS
|
||||
)
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities.extend(
|
||||
EheimDigitalNumber[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
)
|
||||
entities.extend(
|
||||
EheimDigitalNumber[EheimDigitalDevice](coordinator, device, description)
|
||||
for description in GENERAL_DESCRIPTIONS
|
||||
|
||||
@@ -7,11 +7,9 @@ from typing import Any, Literal, override
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
from eheimdigital.types import (
|
||||
FilterMode,
|
||||
FilterModeProf,
|
||||
ReeflexMode,
|
||||
UnitOfMeasurement as EheimDigitalUnitOfMeasurement,
|
||||
)
|
||||
|
||||
@@ -38,20 +36,6 @@ class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
|
||||
set_value_fn: Callable[[_DeviceT, str], Awaitable[None] | None]
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalSelectDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalSelectDescription[EheimDigitalReeflexUV](
|
||||
key="mode",
|
||||
translation_key="mode",
|
||||
value_fn=lambda device: device.mode.name.lower(),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_mode(ReeflexMode[value.upper()])
|
||||
),
|
||||
options=[name.lower() for name in ReeflexMode.__members__],
|
||||
),
|
||||
)
|
||||
|
||||
FILTER_DESCRIPTIONS: tuple[EheimDigitalSelectDescription[EheimDigitalFilter], ...] = (
|
||||
EheimDigitalSelectDescription[EheimDigitalFilter](
|
||||
key="filter_mode",
|
||||
@@ -192,13 +176,6 @@ async def async_setup_entry(
|
||||
EheimDigitalFilterSelect(coordinator, device, description)
|
||||
for description in FILTER_DESCRIPTIONS
|
||||
)
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities.extend(
|
||||
EheimDigitalSelect[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -58,12 +58,6 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"booster_time": {
|
||||
"name": "Booster duration"
|
||||
},
|
||||
"daily_burn_time": {
|
||||
"name": "Daily burn duration"
|
||||
},
|
||||
"day_speed": {
|
||||
"name": "Day speed"
|
||||
},
|
||||
@@ -82,7 +76,6 @@
|
||||
"night_temperature_offset": {
|
||||
"name": "Night temperature offset"
|
||||
},
|
||||
"pause_time": { "name": "Pause duration" },
|
||||
"system_led": {
|
||||
"name": "System LED brightness"
|
||||
},
|
||||
@@ -115,10 +108,6 @@
|
||||
"manual_speed": {
|
||||
"name": "Manual speed"
|
||||
},
|
||||
"mode": {
|
||||
"name": "Operation mode",
|
||||
"state": { "constant": "Constant", "daycycle": "Daycycle" }
|
||||
},
|
||||
"night_speed": {
|
||||
"name": "Night speed"
|
||||
}
|
||||
@@ -138,18 +127,9 @@
|
||||
"operating_time": {
|
||||
"name": "Operating time"
|
||||
},
|
||||
"remaining_booster_time": {
|
||||
"name": "Remaining booster time"
|
||||
},
|
||||
"remaining_pause_time": {
|
||||
"name": "Remaining pause time"
|
||||
},
|
||||
"service_hours": {
|
||||
"name": "Remaining hours until service"
|
||||
},
|
||||
"time_until_next_service": {
|
||||
"name": "Time until next service"
|
||||
},
|
||||
"turn_feeding_time": {
|
||||
"name": "Remaining off time after feeding"
|
||||
},
|
||||
@@ -157,26 +137,12 @@
|
||||
"name": "Remaining off time"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"booster": {
|
||||
"name": "Booster"
|
||||
},
|
||||
"expert": {
|
||||
"name": "Expert mode"
|
||||
},
|
||||
"pause": {
|
||||
"name": "Pause"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"day_start_time": {
|
||||
"name": "Day start time"
|
||||
},
|
||||
"night_start_time": {
|
||||
"name": "Night start time"
|
||||
},
|
||||
"start_time": {
|
||||
"name": "Start time"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"""EHEIM Digital switches."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -21,50 +17,6 @@ from .entity import EheimDigitalEntity, exception_handler
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalSwitchDescription[_DeviceT: EheimDigitalDevice](
|
||||
SwitchEntityDescription
|
||||
):
|
||||
"""Class describing EHEIM Digital switch entities."""
|
||||
|
||||
is_on_fn: Callable[[_DeviceT], bool]
|
||||
set_fn: Callable[[_DeviceT, bool], Awaitable[None]]
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
|
||||
key="active",
|
||||
name=None,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda device: device.is_active,
|
||||
set_fn=lambda device, value: device.set_active(active=value),
|
||||
),
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
|
||||
key="pause",
|
||||
translation_key="pause",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda device: device.pause,
|
||||
set_fn=lambda device, value: device.set_pause(pause=value),
|
||||
),
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
|
||||
key="booster",
|
||||
translation_key="booster",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda device: device.booster,
|
||||
set_fn=lambda device, value: device.set_booster(active=value),
|
||||
),
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
|
||||
key="expert",
|
||||
translation_key="expert",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda device: device.expert,
|
||||
set_fn=lambda device, value: device.set_expert(active=value),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EheimDigitalConfigEntry,
|
||||
@@ -80,14 +32,7 @@ async def async_setup_entry(
|
||||
entities: list[SwitchEntity] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, (EheimDigitalClassicVario, EheimDigitalFilter)):
|
||||
entities.append(EheimDigitalFilterSwitch(coordinator, device))
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities.extend(
|
||||
EheimDigitalSwitch[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
)
|
||||
entities.append(EheimDigitalFilterSwitch(coordinator, device)) # noqa: PERF401
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -95,39 +40,6 @@ async def async_setup_entry(
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalSwitch[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], SwitchEntity
|
||||
):
|
||||
"""Represent a EHEIM Digital switch entity."""
|
||||
|
||||
entity_description: EheimDigitalSwitchDescription[_DeviceT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalSwitchDescription[_DeviceT],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital switch entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
return await self.entity_description.set_fn(self._device, True)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
return await self.entity_description.set_fn(self._device, False)
|
||||
|
||||
@override
|
||||
def _async_update_attrs(self) -> None:
|
||||
self._attr_is_on = self.entity_description.is_on_fn(self._device)
|
||||
|
||||
|
||||
class EheimDigitalFilterSwitch(
|
||||
EheimDigitalEntity[EheimDigitalClassicVario | EheimDigitalFilter], SwitchEntity
|
||||
):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user